Compare commits

..

24 Commits

Author SHA1 Message Date
xiaoju b8b557baf6 fix: migrate template test imports from @uncaged/workflow to new packages
小橘 🍊(NEKO Team)
2026-05-09 03:51:48 +00:00
xingyue 727b4bb3ed refactor(workflow): fix tsconfig references, template imports, delete old packages/workflow
- Update root tsconfig.json references: replace packages/workflow with 6 new packages
- Update cli-workflow tsconfig references to new packages
- Add tsconfig references to workflow-util, workflow-runtime, workflow-execute
- Fix workflow-agent-llm, workflow-agent-cursor, workflow-agent-hermes, workflow-util-agent
  tsconfig references (../workflow -> ../workflow-runtime)
- Remove stale @uncaged/workflow deps from agent package.json files
- Change template packages to import buildDescriptor from @uncaged/workflow-register
- Normalize package.json exports field across all new packages
- Delete old packages/workflow/ directory
2026-05-09 11:46:57 +08:00
xingyue 9bbdfc41bd feat(execute): create @uncaged/workflow-execute + CLI migration
Phase 7: Engine + extract + workflow-as-agent merged into execute package.
All CLI imports migrated from @uncaged/workflow to specific packages.
105 CLI tests pass, 0 failures.

Changes:
- New @uncaged/workflow-execute package (engine/, extract/, workflow-as-agent)
- CLI src/ and __tests__/ rewritten to import from split packages
- bundle-validator updated to allow @uncaged/workflow-cas imports
- ensure-uncaged-workflow-symlink creates symlinks for all new packages

Ref: #143, closes #150
2026-05-09 11:35:03 +08:00
xingyue b07f8cf166 feat(register): create @uncaged/workflow-register package
Merges bundle/ + registry/ + config/ modules. The config↔registry
circular dependency is resolved: ProviderConfig and WorkflowConfig
now come from @uncaged/workflow-protocol.

Ref: #143, closes #149
2026-05-09 11:16:27 +08:00
xingyue 1a1e8b3398 feat(cas,reactor): create @uncaged/workflow-cas and @uncaged/workflow-reactor
Phase 4: CAS module extracted with Merkle types, hash functions,
and fs-backed store. Imports CasStore type from protocol.

Phase 5: Reactor (ReAct loop) extracted as independent package.
Only depends on protocol — no cas or engine dependency.

Ref: #143, closes #147, closes #148
2026-05-09 11:11:33 +08:00
xingyue 39d2a61686 refactor(runtime): types down to @uncaged/workflow-protocol
All type definitions now originate from @uncaged/workflow-protocol.
Runtime re-exports them for backward compatibility. Local AdvanceOutcome
duplicate in create-workflow.ts removed (now imported from protocol).

Ref: #143, closes #146
2026-05-09 11:09:38 +08:00
xingyue bf0bc47a3f feat(util): create @uncaged/workflow-util package
Extract pure utility functions from workflow/src/util/ into standalone package.
Types (Result, ok, err) now come from @uncaged/workflow-protocol.

Contains: base32 encoding, ULID generation, structured logger,
storage-root helpers, refs-field normalization.

Ref: #143, closes #145
2026-05-09 11:08:04 +08:00
xingyue 2cffaad127 feat(protocol): create @uncaged/workflow-protocol package
Extract all cross-package type definitions and constructor functions
into a dedicated protocol layer. This is the foundation for the
seven-package split (RFC #143).

Contains:
- Result<T,E>, ok(), err()
- START, END constants
- CasStore, WorkflowFn, RoleOutput, WorkflowCompletion
- WorkflowDescriptor, WorkflowRoleDescriptor
- ProviderConfig, WorkflowConfig, ResolvedModel (fixes config↔registry cycle)
- RoleDefinition, Moderator, WorkflowDefinition
- AgentFn, ExtractFn, and all thread context types

Ref: #143, closes #144
2026-05-09 11:06:10 +08:00
xiaomo 9a3daac657 Merge pull request 'feat(workflow): ThreadReactor — generic ReAct loop + extract/supervisor migration' (#142) from feat/139-thread-reactor into main 2026-05-09 02:28:09 +00:00
xiaoju b8f9ffcb59 feat(workflow): migrate supervisor to ThreadReactor (Phase 2)
- Rewrite supervisor to use createThreadReactor + createLlmFn
- No direct fetch/HTTP calls in supervisor
- All 266 tests passing

Refs #139, relates #141
2026-05-09 02:26:39 +00:00
xiaoju a7171f05f6 feat(workflow): add ThreadReactor generic ReAct loop + migrate extract (Phase 1)
- New src/reactor/ module: createThreadReactor, createLlmFn, types
- Two-stage API: config (llm, systemPrompt, tools, toolHandler) + per-call (thread, input, schema)
- All tool failures are recoverable (returned to LLM as error message)
- Rewrite createExtract to use createThreadReactor
- Delete reactExtract old implementation
- Fix template test imports (START/END from runtime, validateWorkflowDescriptor from engine)

268 tests passing.

Refs #139, relates #140
2026-05-09 02:15:38 +00:00
xiaoju b53667a2aa Merge pull request 'refactor(workflow): move descriptor validation out of runtime' (#135) from refactor/runtime-descriptor-boundary into main 2026-05-08 15:05:24 +00:00
Scott Wei 5b60fa6454 refactor(workflow-runtime): flatten package layout and centralize types
Collapse bundle/cas/extract/util stubs into types.ts; move createWorkflow and Result helpers to src root.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 23:03:53 +08:00
xiaomo 2c0e744ebf Merge pull request 'perf(serve): SSE live pump reads incrementally' (#137) from fix/130-sse-incremental into main 2026-05-08 15:00:44 +00:00
xiaomo ae16f09688 Merge pull request 'feat(dashboard): hash routing + health check polling' (#138) from fix/128-dashboard-enhancements into main 2026-05-08 15:00:34 +00:00
xiaomo 73a3638ad9 Merge pull request 'fix(serve): error handling, CORS, body limit, CAS store reuse' (#136) from fix/120-serve-hardening into main 2026-05-08 15:00:32 +00:00
xingyue 7b0260cedd feat(dashboard): hash routing + health check polling
- Hash-based URL routing (#threads, #threads/{id}, #workflows)
  for bookmarkable/shareable thread links
- Health check polls every 10s with reconnecting state
- useHashRoute hook for clean route management

Closes #128
2026-05-08 18:16:09 +08:00
xingyue 61fc1cfe1b perf(serve): SSE live pump reads incrementally instead of full file
Use Bun.file().slice() to read only new bytes from the last known
offset instead of re-reading the entire JSONL file on every fs watch.

- readNewBytes() helper with byte-offset tracking
- Handles file truncation (resets offset)
- Early return when no new data

Closes #130
2026-05-08 18:14:04 +08:00
xingyue 6b1e728700 fix(serve): error handling, CORS, body limit, CAS store reuse
- Global error handler (app.onError → 500 JSON)
- JSON parse validation on POST routes (400)
- CORS restricted to localhost origins
- 1MB body size limit on POST (413)
- CAS store created once per route group, not per-request
- 6 new tests covering all changes

Closes #120
2026-05-08 18:11:59 +08:00
xiaomo dedab62c49 Merge pull request 'feat(dashboard): connect thread detail to SSE live stream' (#134) from feat/131-dashboard-sse into main 2026-05-08 09:53:10 +00:00
xingyue a44f1f34a8 feat(dashboard): connect thread detail to SSE live stream
Add useSSE hook that connects to /api/threads/:id/live via EventSource.
Thread detail page now shows records in real-time with auto-scroll.

- useSSE hook: EventSource connection, record accumulation, auto-reconnect
  with exponential backoff, cleanup on unmount
- Thread detail: Live badge, SSE-first with fetch fallback, smooth scroll
- Records clear on reconnect (server replays full file)

Closes #131, testing verified per #133
Refs: #118
2026-05-08 17:52:10 +08:00
Scott Wei 8ff6f7e778 refactor(workflow): move descriptor validation out of runtime
Keep @uncaged/workflow-runtime focused on bundle runtime capabilities by relocating descriptor validation implementation to @uncaged/workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 17:45:15 +08:00
xiaoju e04e75bdee chore: remove stale self-referencing symlink
小橘 🍊(NEKO Team)
2026-05-08 09:35:32 +00:00
xiaoju c65c29c1b5 Merge pull request 'refactor(workflow): simplify extraction + thread runtime contract' (#132) from refactor/thread-context-runtime into main 2026-05-08 09:34:26 +00:00
181 changed files with 1917 additions and 4370 deletions
@@ -3,13 +3,9 @@ import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promise
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { import { getGlobalCasDir } from "@uncaged/workflow-util";
createContentMerkleNode, import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
getGlobalCasDir, import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
getRegisteredWorkflow,
readWorkflowRegistry,
serializeMerkleNode,
} from "@uncaged/workflow";
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js"; import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js";
import { import {
cmdAdd, cmdAdd,
@@ -25,7 +21,7 @@ import { addCliArgs } from "./bundle-fixture.js";
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} }; const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} };
`; `;
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow"; const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
`; `;
function casStoredForm(raw: string): string { function casStoredForm(raw: string): string {
@@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { createCasStore, getContentMerklePayload, getGlobalCasDir } from "@uncaged/workflow"; import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { cmdFork, cmdRun } from "../src/commands/thread/index.js"; import { cmdFork, cmdRun } from "../src/commands/thread/index.js";
import { cmdAdd } from "../src/commands/workflow/index.js"; import { cmdAdd } from "../src/commands/workflow/index.js";
import { pathExists } from "../src/fs-utils.js"; import { pathExists } from "../src/fs-utils.js";
@@ -10,7 +11,7 @@ import { addCliArgs } from "./bundle-fixture.js";
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js"; import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
/** Three-role workflow that respects `input.steps` for fork/resume. */ /** Three-role workflow that respects `input.steps` for fork/resume. */
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow"; const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
export const descriptor = { export const descriptor = {
description: "fork-cli", description: "fork-cli",
@@ -4,12 +4,9 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { import { createCasStore, putContentMerkleNode } from "@uncaged/workflow-cas";
createCasStore, import { getGlobalCasDir } from "@uncaged/workflow-util";
garbageCollectCas, import { garbageCollectCas } from "@uncaged/workflow-execute";
getGlobalCasDir,
putContentMerkleNode,
} from "@uncaged/workflow";
import { cmdThreadRemove } from "../src/commands/thread/index.js"; import { cmdThreadRemove } from "../src/commands/thread/index.js";
import { pathExists } from "../src/fs-utils.js"; import { pathExists } from "../src/fs-utils.js";
@@ -50,7 +50,6 @@ describe("init template", () => {
dependencies: Record<string, string>; dependencies: Record<string, string>;
}; };
expect(pkg.type).toBe("module"); expect(pkg.type).toBe("module");
expect(pkg.dependencies["@uncaged/workflow"]).toBeDefined();
expect(pkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined(); expect(pkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
expect(pkg.dependencies.zod).toBeDefined(); expect(pkg.dependencies.zod).toBeDefined();
expect(pkg.name).toContain("review-pr"); expect(pkg.name).toContain("review-pr");
@@ -46,7 +46,7 @@ describe("init workspace", () => {
dependencies: Record<string, string>; dependencies: Record<string, string>;
}; };
expect(wfPkg.type).toBe("module"); expect(wfPkg.type).toBe("module");
expect(wfPkg.dependencies["@uncaged/workflow"]).toBeDefined(); expect(wfPkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
expect(wfPkg.dependencies.zod).toBeDefined(); expect(wfPkg.dependencies.zod).toBeDefined();
const tsconfig = JSON.parse(await readFile(join(root, "tsconfig.json"), "utf8")) as { const tsconfig = JSON.parse(await readFile(join(root, "tsconfig.json"), "utf8")) as {
+2 -1
View File
@@ -5,7 +5,8 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { createCasStore, getGlobalCasDir, putContentMerkleNode } from "@uncaged/workflow"; import { createCasStore, putContentMerkleNode } from "@uncaged/workflow-cas";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { import {
formatLiveDebugLine, formatLiveDebugLine,
+78 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow"; import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
import { createApp } from "../src/commands/serve/app.js"; import { createApp } from "../src/commands/serve/app.js";
@@ -77,6 +77,83 @@ describe("serve /api/cas", () => {
}); });
}); });
describe("serve error handling", () => {
test("POST /api/threads with invalid JSON body → 400", async () => {
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
const res = await fetch("/api/threads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "not json",
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("invalid JSON body");
});
test("POST /api/cas with invalid JSON body → 400", async () => {
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
const res = await fetch("/api/cas", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "not json",
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("invalid JSON body");
});
test("POST /api/threads with missing required fields → 400", async () => {
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
const res = await fetch("/api/threads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ foo: "bar" }),
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toContain("required");
});
test("global error handler returns 500 with JSON", async () => {
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
app.get("/test-error", () => {
throw new Error("boom");
});
const res = await app.fetch(new Request("http://localhost/test-error"));
expect(res.status).toBe(500);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("Internal server error");
});
});
describe("serve security", () => {
test("CORS headers present on responses", async () => {
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
const res2 = await app.fetch(
new Request("http://localhost/healthz", {
headers: { Origin: "http://localhost:5173" },
}),
);
expect(res2.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:5173");
});
test("POST with body > 1MB → 413", async () => {
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
const largeBody = "x".repeat(1_048_577);
const res = await fetch("/api/cas", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": String(largeBody.length),
},
body: largeBody,
});
expect(res.status).toBe(413);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("Payload too large");
});
});
describe("serve CAS round-trip", () => { describe("serve CAS round-trip", () => {
const tmpDir = `/tmp/uncaged-serve-cas-test-${Date.now()}`; const tmpDir = `/tmp/uncaged-serve-cas-test-${Date.now()}`;
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow"; import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
import { resolveWorkflowStorageRoot } from "../src/storage-env.js"; import { resolveWorkflowStorageRoot } from "../src/storage-env.js";
describe("resolveWorkflowStorageRoot", () => { describe("resolveWorkflowStorageRoot", () => {
@@ -4,7 +4,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { getGlobalCasDir } from "@uncaged/workflow"; import { getGlobalCasDir } from "@uncaged/workflow-util";
import { cmdCasPut } from "../src/commands/cas/index.js"; import { cmdCasPut } from "../src/commands/cas/index.js";
import { import {
cmdKill, cmdKill,
@@ -21,7 +21,7 @@ import { pathExists, readTextFileIfExists } from "../src/fs-utils.js";
import { addCliArgs } from "./bundle-fixture.js"; import { addCliArgs } from "./bundle-fixture.js";
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js"; import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow"; const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
`; `;
const threadFixtureDescriptor = `export const descriptor = { const threadFixtureDescriptor = `export const descriptor = {
+5 -1
View File
@@ -6,8 +6,12 @@
"uncaged-workflow": "src/cli.ts" "uncaged-workflow": "src/cli.ts"
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-util": "workspace:*",
"@uncaged/workflow-cas": "workspace:*",
"@uncaged/workflow-execute": "workspace:*",
"@uncaged/workflow-register": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*", "@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow": "workspace:*",
"hono": "^4.12.18", "hono": "^4.12.18",
"yaml": "^2.8.4" "yaml": "^2.8.4"
}, },
+1 -1
View File
@@ -1,7 +1,7 @@
import { mkdir, readFile, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow"; import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { pathExists } from "./fs-utils.js"; import { pathExists } from "./fs-utils.js";
+2 -1
View File
@@ -1,4 +1,5 @@
import { type GcResult, garbageCollectCas, type Result } from "@uncaged/workflow"; import type { Result } from "@uncaged/workflow-protocol";
import { type GcResult, garbageCollectCas } from "@uncaged/workflow-execute";
export async function cmdGc(storageRoot: string): Promise<Result<GcResult, string>> { export async function cmdGc(storageRoot: string): Promise<Result<GcResult, string>> {
return garbageCollectCas(storageRoot); return garbageCollectCas(storageRoot);
@@ -1,4 +1,6 @@
import { createCasStore, err, getGlobalCasDir, ok, type Result } from "@uncaged/workflow"; import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { createCasStore } from "@uncaged/workflow-cas";
export async function cmdCasGet( export async function cmdCasGet(
storageRoot: string, storageRoot: string,
@@ -1,4 +1,6 @@
import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow"; import { ok, type Result } from "@uncaged/workflow-protocol";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { createCasStore } from "@uncaged/workflow-cas";
export async function cmdCasList(storageRoot: string): Promise<Result<string[], string>> { export async function cmdCasList(storageRoot: string): Promise<Result<string[], string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot)); const cas = createCasStore(getGlobalCasDir(storageRoot));
@@ -1,4 +1,6 @@
import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow"; import { ok, type Result } from "@uncaged/workflow-protocol";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { createCasStore } from "@uncaged/workflow-cas";
export async function cmdCasPut( export async function cmdCasPut(
storageRoot: string, storageRoot: string,
+3 -1
View File
@@ -1,4 +1,6 @@
import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow"; import { ok, type Result } from "@uncaged/workflow-protocol";
import { getGlobalCasDir } from "@uncaged/workflow-util";
import { createCasStore } from "@uncaged/workflow-cas";
export async function cmdCasRm(storageRoot: string, hash: string): Promise<Result<void, string>> { export async function cmdCasRm(storageRoot: string, hash: string): Promise<Result<void, string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot)); const cas = createCasStore(getGlobalCasDir(storageRoot));
@@ -1,7 +1,7 @@
import { mkdir, readFile, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow"; import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { pathExists } from "../../fs-utils.js"; import { pathExists } from "../../fs-utils.js";
@@ -6,7 +6,6 @@ export function templatePackageJson(templateName: string): string {
private: true, private: true,
type: "module", type: "module",
dependencies: { dependencies: {
"@uncaged/workflow": "^0.1.0",
"@uncaged/workflow-runtime": "^0.1.0", "@uncaged/workflow-runtime": "^0.1.0",
zod: "^4.0.0", zod: "^4.0.0",
}, },
@@ -1,4 +1,4 @@
import { err, ok, type Result } from "@uncaged/workflow"; import { err, ok, type Result } from "@uncaged/workflow-protocol";
/** Validates a single path segment for workspace / template names (no separators, not `.` / `..`). */ /** Validates a single path segment for workspace / template names (no separators, not `.` / `..`). */
export function validateWorkspaceSegment(name: string): Result<void, string> { export function validateWorkspaceSegment(name: string): Result<void, string> {
@@ -1,7 +1,7 @@
import { mkdir, writeFile } from "node:fs/promises"; import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow"; import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { pathExists } from "../../fs-utils.js"; import { pathExists } from "../../fs-utils.js";
import type { CmdInitWorkspaceSuccess } from "./types.js"; import type { CmdInitWorkspaceSuccess } from "./types.js";
@@ -28,7 +28,7 @@ function workflowsPackageJson(): string {
private: true, private: true,
type: "module", type: "module",
dependencies: { dependencies: {
"@uncaged/workflow": "^0.1.0", "@uncaged/workflow-runtime": "^0.1.0",
zod: "^4.0.0", zod: "^4.0.0",
}, },
}, },
@@ -6,10 +6,36 @@ import { createLiveRoutes } from "./routes-live.js";
import { createThreadRoutes } from "./routes-thread.js"; import { createThreadRoutes } from "./routes-thread.js";
import { createWorkflowRoutes } from "./routes-workflow.js"; import { createWorkflowRoutes } from "./routes-workflow.js";
const MAX_BODY_SIZE = 1_048_576; // 1 MB
export function createApp(storageRoot: string): Hono { export function createApp(storageRoot: string): Hono {
const app = new Hono(); const app = new Hono();
app.use("*", cors()); app.onError((_err, c) => {
return c.json({ error: "Internal server error" }, 500);
});
app.use(
"*",
cors({
origin: [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:7860",
"http://127.0.0.1:7860",
],
}),
);
app.use("*", async (c, next) => {
if (c.req.method === "POST") {
const contentLength = c.req.header("content-length");
if (contentLength !== undefined && Number(contentLength) > MAX_BODY_SIZE) {
return c.json({ error: "Payload too large" }, 413);
}
}
await next();
});
app.get("/healthz", (c) => c.json({ ok: true })); app.get("/healthz", (c) => c.json({ ok: true }));
@@ -1,19 +1,19 @@
import { createCasStore, garbageCollectCas, getGlobalCasDir } from "@uncaged/workflow"; import { getGlobalCasDir } from "@uncaged/workflow-util";
import { createCasStore } from "@uncaged/workflow-cas";
import { garbageCollectCas } from "@uncaged/workflow-execute";
import { Hono } from "hono"; import { Hono } from "hono";
export function createCasRoutes(storageRoot: string): Hono { export function createCasRoutes(storageRoot: string): Hono {
const app = new Hono(); const app = new Hono();
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
app.get("/", async (c) => { app.get("/", async (c) => {
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
const hashes = await cas.list(); const hashes = await cas.list();
return c.json({ hashes }); return c.json({ hashes });
}); });
app.get("/:hash", async (c) => { app.get("/:hash", async (c) => {
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
const content = await cas.get(c.req.param("hash")); const content = await cas.get(c.req.param("hash"));
if (content === null) { if (content === null) {
return c.json({ error: "not found" }, 404); return c.json({ error: "not found" }, 404);
@@ -22,19 +22,20 @@ export function createCasRoutes(storageRoot: string): Hono {
}); });
app.post("/", async (c) => { app.post("/", async (c) => {
const body = await c.req.json<{ content: string }>(); let body: { content: string };
try {
body = (await c.req.json()) as { content: string };
} catch {
return c.json({ error: "invalid JSON body" }, 400);
}
if (typeof body.content !== "string") { if (typeof body.content !== "string") {
return c.json({ error: "content field required" }, 400); return c.json({ error: "content field required" }, 400);
} }
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
const hash = await cas.put(body.content); const hash = await cas.put(body.content);
return c.json({ hash }, 201); return c.json({ hash }, 201);
}); });
app.delete("/:hash", async (c) => { app.delete("/:hash", async (c) => {
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
const hash = c.req.param("hash"); const hash = c.req.param("hash");
const content = await cas.get(hash); const content = await cas.get(hash);
if (content === null) { if (content === null) {
@@ -1,5 +1,4 @@
import { watch } from "node:fs"; import { statSync, watch } from "node:fs";
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { Hono } from "hono"; import { Hono } from "hono";
import { streamSSE } from "hono/streaming"; import { streamSSE } from "hono/streaming";
@@ -11,6 +10,30 @@ type PumpState = {
carry: string; carry: string;
}; };
function fileSize(path: string): number {
try {
return statSync(path).size;
} catch {
return 0;
}
}
async function readNewBytes(path: string, state: PumpState): Promise<string | null> {
const size = fileSize(path);
if (size < state.contentOffset) {
// File was truncated — reset
state.contentOffset = 0;
state.carry = "";
}
if (size <= state.contentOffset) {
return null;
}
const blob = Bun.file(path).slice(state.contentOffset, size);
const chunk = await blob.text();
state.contentOffset = size;
return chunk;
}
function parseJsonLine(line: string): unknown { function parseJsonLine(line: string): unknown {
try { try {
return JSON.parse(line) as unknown; return JSON.parse(line) as unknown;
@@ -28,14 +51,7 @@ function isWorkflowResult(record: unknown): boolean {
); );
} }
function parseNewLines(text: string, state: PumpState): string[] { function parseNewLines(chunk: string, state: PumpState): string[] {
if (text.length < state.contentOffset) {
state.contentOffset = 0;
state.carry = "";
}
const chunk = text.slice(state.contentOffset);
state.contentOffset = text.length;
state.carry += chunk; state.carry += chunk;
const parts = state.carry.split("\n"); const parts = state.carry.split("\n");
@@ -70,14 +86,17 @@ export function createLiveRoutes(storageRoot: string): Hono {
let eventId = 0; let eventId = 0;
async function pumpData(): Promise<boolean> { async function pumpData(): Promise<boolean> {
let text: string; let chunk: string | null;
try { try {
text = await readFile(resolvedDataPath, "utf8"); chunk = await readNewBytes(resolvedDataPath, dataState);
} catch { } catch {
return false; return false;
} }
if (chunk === null) {
return false;
}
const lines = parseNewLines(text, dataState); const lines = parseNewLines(chunk, dataState);
for (const line of lines) { for (const line of lines) {
const record = parseJsonLine(line); const record = parseJsonLine(line);
eventId++; eventId++;
@@ -95,14 +114,17 @@ export function createLiveRoutes(storageRoot: string): Hono {
} }
async function pumpInfo(): Promise<void> { async function pumpInfo(): Promise<void> {
let text: string; let chunk: string | null;
try { try {
text = await readFile(infoPath, "utf8"); chunk = await readNewBytes(infoPath, infoState);
} catch { } catch {
return; return;
} }
if (chunk === null) {
return;
}
const lines = parseNewLines(text, infoState); const lines = parseNewLines(chunk, infoState);
for (const line of lines) { for (const line of lines) {
const record = parseJsonLine(line); const record = parseJsonLine(line);
if ( if (
@@ -2,7 +2,7 @@ import {
getRegisteredWorkflow, getRegisteredWorkflow,
listRegisteredWorkflowNames, listRegisteredWorkflowNames,
readWorkflowRegistry, readWorkflowRegistry,
} from "@uncaged/workflow"; } from "@uncaged/workflow-register";
import { Hono } from "hono"; import { Hono } from "hono";
export function createWorkflowRoutes(storageRoot: string): Hono { export function createWorkflowRoutes(storageRoot: string): Hono {
@@ -1,4 +1,4 @@
import { err, ok, type Result } from "@uncaged/workflow"; import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { serve } from "bun"; import { serve } from "bun";
import { printCliLine } from "../../cli-output.js"; import { printCliLine } from "../../cli-output.js";
@@ -1,4 +1,4 @@
import type { Result } from "@uncaged/workflow"; import type { Result } from "@uncaged/workflow-protocol";
import { import {
readWorkerCtl, readWorkerCtl,
@@ -1,4 +1,4 @@
import { err, ok, type Result } from "@uncaged/workflow"; import { err, ok, type Result } from "@uncaged/workflow-protocol";
import type { ParsedForkArgv } from "./types.js"; import type { ParsedForkArgv } from "./types.js";
@@ -1,6 +1,8 @@
import { join } from "node:path"; import { join } from "node:path";
import { buildForkPlan, err, generateUlid, ok, type Result } from "@uncaged/workflow"; import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { generateUlid } from "@uncaged/workflow-util";
import { buildForkPlan } from "@uncaged/workflow-execute";
import { pathExists, readTextFileIfExists } from "../../fs-utils.js"; import { pathExists, readTextFileIfExists } from "../../fs-utils.js";
import { resolveThreadDataPath } from "../../thread-scan.js"; import { resolveThreadDataPath } from "../../thread-scan.js";
@@ -1,4 +1,4 @@
import { err, ok, type Result } from "@uncaged/workflow"; import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { listHistoricalThreads } from "../../thread-scan.js"; import { listHistoricalThreads } from "../../thread-scan.js";
import { validateCliWorkflowName } from "../../workflow-name.js"; import { validateCliWorkflowName } from "../../workflow-name.js";
@@ -2,15 +2,10 @@ import { watch } from "node:fs";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { import type { CasStore, WorkflowCompletion } from "@uncaged/workflow-protocol";
type CasStore, import { getGlobalCasDir } from "@uncaged/workflow-util";
createCasStore, import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
getContentMerklePayload, import { tryParseRoleStepRecord, tryParseWorkflowResultRecord } from "@uncaged/workflow-execute";
getGlobalCasDir,
tryParseRoleStepRecord,
tryParseWorkflowResultRecord,
} from "@uncaged/workflow";
import type { WorkflowCompletion } from "@uncaged/workflow-runtime";
import { dimGreyLine, highlightLiveRole } from "../../cli-color.js"; import { dimGreyLine, highlightLiveRole } from "../../cli-color.js";
import { printCliError, printCliLine } from "../../cli-output.js"; import { printCliError, printCliLine } from "../../cli-output.js";
@@ -1,7 +1,8 @@
import { unlink } from "node:fs/promises"; import { unlink } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { err, garbageCollectCas, ok, type Result } from "@uncaged/workflow"; import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { garbageCollectCas } from "@uncaged/workflow-execute";
import { resolveThreadDataPath } from "../../thread-scan.js"; import { resolveThreadDataPath } from "../../thread-scan.js";
@@ -1,13 +1,8 @@
import { join } from "node:path"; import { join } from "node:path";
import { import { err, ok, type Result } from "@uncaged/workflow-protocol";
err, import { generateUlid } from "@uncaged/workflow-util";
generateUlid, import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
getRegisteredWorkflow,
ok,
type Result,
readWorkflowRegistry,
} from "@uncaged/workflow";
import { ensureWorkerForHash, sendWorkerTcpCommand } from "../../worker-spawn.js"; import { ensureWorkerForHash, sendWorkerTcpCommand } from "../../worker-spawn.js";
import { validateCliWorkflowName } from "../../workflow-name.js"; import { validateCliWorkflowName } from "../../workflow-name.js";
@@ -1,4 +1,4 @@
import { err, ok, type Result } from "@uncaged/workflow"; import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { readTextFileIfExists } from "../../fs-utils.js"; import { readTextFileIfExists } from "../../fs-utils.js";
import { resolveThreadDataPath } from "../../thread-scan.js"; import { resolveThreadDataPath } from "../../thread-scan.js";
@@ -1,4 +1,4 @@
import { err, ok, type Result } from "@uncaged/workflow"; import { err, ok, type Result } from "@uncaged/workflow-protocol";
import type { ParsedAddArgv } from "./types.js"; import type { ParsedAddArgv } from "./types.js";
@@ -1,18 +1,16 @@
import { readFile, stat } from "node:fs/promises"; import { readFile, stat } from "node:fs/promises";
import { basename, resolve } from "node:path"; import { basename, resolve } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { hashWorkflowBundleBytes } from "@uncaged/workflow-cas";
import { import {
err,
extractBundleExports, extractBundleExports,
hashWorkflowBundleBytes,
ok,
type Result,
readWorkflowRegistry, readWorkflowRegistry,
registerWorkflowVersion, registerWorkflowVersion,
stringifyWorkflowDescriptor, stringifyWorkflowDescriptor,
validateWorkflowBundle, validateWorkflowBundle,
writeWorkflowRegistry, writeWorkflowRegistry,
} from "@uncaged/workflow"; } from "@uncaged/workflow-register";
import { storeWorkflowBundleArtifacts } from "../../bundle-store.js"; import { storeWorkflowBundleArtifacts } from "../../bundle-store.js";
import { validateCliWorkflowName } from "../../workflow-name.js"; import { validateCliWorkflowName } from "../../workflow-name.js";
@@ -1,10 +1,5 @@
import { import { err, ok, type Result } from "@uncaged/workflow-protocol";
err, import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
getRegisteredWorkflow,
ok,
type Result,
readWorkflowRegistry,
} from "@uncaged/workflow";
import { validateCliWorkflowName } from "../../workflow-name.js"; import { validateCliWorkflowName } from "../../workflow-name.js";
@@ -1,11 +1,9 @@
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { import {
err,
listRegisteredWorkflowNames, listRegisteredWorkflowNames,
ok,
type Result,
readWorkflowRegistry, readWorkflowRegistry,
type WorkflowRegistryFile, type WorkflowRegistryFile,
} from "@uncaged/workflow"; } from "@uncaged/workflow-register";
export async function cmdList(storageRoot: string): Promise<Result<WorkflowRegistryFile, string>> { export async function cmdList(storageRoot: string): Promise<Result<WorkflowRegistryFile, string>> {
const reg = await readWorkflowRegistry(storageRoot); const reg = await readWorkflowRegistry(storageRoot);
@@ -1,11 +1,9 @@
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { import {
err,
ok,
type Result,
readWorkflowRegistry, readWorkflowRegistry,
unregisterWorkflow, unregisterWorkflow,
writeWorkflowRegistry, writeWorkflowRegistry,
} from "@uncaged/workflow"; } from "@uncaged/workflow-register";
import { validateCliWorkflowName } from "../../workflow-name.js"; import { validateCliWorkflowName } from "../../workflow-name.js";
@@ -1,14 +1,12 @@
import { join } from "node:path"; import { join } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { import {
err,
getRegisteredWorkflow, getRegisteredWorkflow,
ok,
type Result,
readWorkflowRegistry, readWorkflowRegistry,
rollbackWorkflowToHistoryHash, rollbackWorkflowToHistoryHash,
writeWorkflowRegistry, writeWorkflowRegistry,
} from "@uncaged/workflow"; } from "@uncaged/workflow-register";
import { pathExists } from "../../fs-utils.js"; import { pathExists } from "../../fs-utils.js";
import { validateCliWorkflowName } from "../../workflow-name.js"; import { validateCliWorkflowName } from "../../workflow-name.js";
@@ -1,11 +1,9 @@
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { import {
err,
getRegisteredWorkflow, getRegisteredWorkflow,
ok,
type Result,
readWorkflowRegistry, readWorkflowRegistry,
type WorkflowRegistryEntry, type WorkflowRegistryEntry,
} from "@uncaged/workflow"; } from "@uncaged/workflow-register";
import { stringify } from "yaml"; import { stringify } from "yaml";
import { validateCliWorkflowName } from "../../workflow-name.js"; import { validateCliWorkflowName } from "../../workflow-name.js";
+1 -1
View File
@@ -1,4 +1,4 @@
import { err, ok, type Result } from "@uncaged/workflow"; import { err, ok, type Result } from "@uncaged/workflow-protocol";
export type ParsedLiveArgv = { export type ParsedLiveArgv = {
threadId: string | null; threadId: string | null;
+1 -1
View File
@@ -1,4 +1,4 @@
import { err, ok, type Result } from "@uncaged/workflow"; import { err, ok, type Result } from "@uncaged/workflow-protocol";
export type ParsedRunArgv = { export type ParsedRunArgv = {
name: string; name: string;
+1 -1
View File
@@ -1,4 +1,4 @@
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow"; import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
/** /**
* Resolve storage root with env var override support. * Resolve storage root with env var override support.
+2 -1
View File
@@ -3,7 +3,8 @@ import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
import { createConnection } from "node:net"; import { createConnection } from "node:net";
import { join } from "node:path"; import { join } from "node:path";
import { err, getWorkerHostScriptPath, ok, type Result } from "@uncaged/workflow"; import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { getWorkerHostScriptPath } from "@uncaged/workflow-execute";
import { pathExists, readTextFileIfExists } from "./fs-utils.js"; import { pathExists, readTextFileIfExists } from "./fs-utils.js";
+1 -1
View File
@@ -1,4 +1,4 @@
import { err, ok, type Result } from "@uncaged/workflow"; import { err, ok, type Result } from "@uncaged/workflow-protocol";
const WORKFLOW_NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; const WORKFLOW_NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
+8 -1
View File
@@ -17,6 +17,13 @@
"rootDir": "src", "rootDir": "src",
"types": ["bun-types"] "types": ["bun-types"]
}, },
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow" }], "references": [
{ "path": "../workflow-runtime" },
{ "path": "../workflow-protocol" },
{ "path": "../workflow-util" },
{ "path": "../workflow-cas" },
{ "path": "../workflow-execute" },
{ "path": "../workflow-register" }
],
"include": ["src/**/*.ts"] "include": ["src/**/*.ts"]
} }
+6 -9
View File
@@ -5,12 +5,10 @@ import { StatusBar } from "./components/status-bar.tsx";
import { ThreadDetail } from "./components/thread-detail.tsx"; import { ThreadDetail } from "./components/thread-detail.tsx";
import { ThreadList } from "./components/thread-list.tsx"; import { ThreadList } from "./components/thread-list.tsx";
import { WorkflowList } from "./components/workflow-list.tsx"; import { WorkflowList } from "./components/workflow-list.tsx";
import { useHashRoute } from "./use-hash-route.ts";
type View = "threads" | "workflows";
export function App() { export function App() {
const [view, setView] = useState<View>("threads"); const { view, threadId, setView, setThreadId } = useHashRoute();
const [selectedThread, setSelectedThread] = useState<string | null>(null);
const [showRun, setShowRun] = useState(false); const [showRun, setShowRun] = useState(false);
return ( return (
@@ -19,9 +17,9 @@ export function App() {
<main className="flex-1 overflow-hidden flex flex-col"> <main className="flex-1 overflow-hidden flex flex-col">
<StatusBar onRun={() => setShowRun(true)} /> <StatusBar onRun={() => setShowRun(true)} />
<div className="flex-1 overflow-auto p-6"> <div className="flex-1 overflow-auto p-6">
{view === "threads" && !selectedThread && <ThreadList onSelect={setSelectedThread} />} {view === "threads" && threadId === null && <ThreadList onSelect={setThreadId} />}
{view === "threads" && selectedThread && ( {view === "threads" && threadId !== null && (
<ThreadDetail threadId={selectedThread} onBack={() => setSelectedThread(null)} /> <ThreadDetail threadId={threadId} onBack={() => setThreadId(null)} />
)} )}
{view === "workflows" && <WorkflowList />} {view === "workflows" && <WorkflowList />}
</div> </div>
@@ -31,8 +29,7 @@ export function App() {
onClose={() => setShowRun(false)} onClose={() => setShowRun(false)}
onCreated={(id) => { onCreated={(id) => {
setShowRun(false); setShowRun(false);
setView("threads"); setThreadId(id);
setSelectedThread(id);
}} }}
/> />
)} )}
@@ -1,12 +1,47 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getHealth } from "../api.ts"; import { getHealth } from "../api.ts";
import { useFetch } from "../hooks.ts";
type HealthStatus = "connected" | "disconnected" | "reconnecting";
type Props = { type Props = {
onRun: () => void; onRun: () => void;
}; };
function statusLabel(status: HealthStatus): { text: string; color: string } {
if (status === "connected") {
return { text: "● Connected", color: "var(--color-success)" };
}
if (status === "reconnecting") {
return { text: "● Reconnecting...", color: "var(--color-warning, #f59e0b)" };
}
return { text: "● Offline", color: "var(--color-error)" };
}
export function StatusBar({ onRun }: Props) { export function StatusBar({ onRun }: Props) {
const health = useFetch(() => getHealth(), []); const [status, setStatus] = useState<HealthStatus>("disconnected");
const wasConnectedRef = useRef(false);
const checkHealth = useCallback(async () => {
try {
await getHealth();
wasConnectedRef.current = true;
setStatus("connected");
} catch {
if (wasConnectedRef.current) {
setStatus("reconnecting");
} else {
setStatus("disconnected");
}
}
}, []);
useEffect(() => {
checkHealth();
const interval = setInterval(checkHealth, 10_000);
return () => clearInterval(interval);
}, [checkHealth]);
const label = statusLabel(status);
return ( return (
<div <div
@@ -24,15 +59,7 @@ export function StatusBar({ onRun }: Props) {
Run Thread Run Thread
</button> </button>
</div> </div>
<span> <span style={{ color: label.color }}>{label.text}</span>
{health.status === "loading" && "⏳ Connecting..."}
{health.status === "ok" && (
<span style={{ color: "var(--color-success)" }}> Connected</span>
)}
{health.status === "error" && (
<span style={{ color: "var(--color-error)" }}> Offline</span>
)}
</span>
</div> </div>
); );
} }
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { getThread, killThread, pauseThread, resumeThread } from "../api.ts"; import { getThread, killThread, pauseThread, resumeThread } from "../api.ts";
import { useFetch } from "../hooks.ts"; import { useFetch } from "../hooks.ts";
import { useSSE } from "../use-sse.ts";
type Props = { type Props = {
threadId: string; threadId: string;
@@ -8,8 +9,22 @@ type Props = {
}; };
export function ThreadDetail({ threadId, onBack }: Props) { export function ThreadDetail({ threadId, onBack }: Props) {
const sse = useSSE(threadId);
const { status, data, error } = useFetch(() => getThread(threadId), [threadId]); const { status, data, error } = useFetch(() => getThread(threadId), [threadId]);
const [actionStatus, setActionStatus] = useState<string | null>(null); const [actionStatus, setActionStatus] = useState<string | null>(null);
const recordsEndRef = useRef<HTMLDivElement>(null);
const liveActive = sse.connected && !sse.completed;
const records = liveActive
? sse.records
: status === "ok"
? data.records
: ([] as typeof sse.records);
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the rendered record list grows
useEffect(() => {
recordsEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [records.length]);
async function handleAction(action: "kill" | "pause" | "resume") { async function handleAction(action: "kill" | "pause" | "resume") {
setActionStatus(`${action}ing...`); setActionStatus(`${action}ing...`);
@@ -61,20 +76,34 @@ export function ThreadDetail({ threadId, onBack }: Props) {
</div> </div>
</div> </div>
<h2 className="text-xl font-semibold mb-2 font-mono">{threadId}</h2> <h2 className="text-xl font-semibold mb-2 font-mono flex items-center gap-2 flex-wrap">
<span>{threadId}</span>
{sse.connected && (
<span
className="text-xs font-medium px-2 py-0.5 rounded"
style={{ background: "var(--color-success)", color: "var(--color-bg)" }}
>
Live
</span>
)}
</h2>
{actionStatus && ( {actionStatus && (
<p className="text-xs mb-4" style={{ color: "var(--color-text-muted)" }}> <p className="text-xs mb-4" style={{ color: "var(--color-text-muted)" }}>
{actionStatus} {actionStatus}
</p> </p>
)} )}
{status === "loading" && <p style={{ color: "var(--color-text-muted)" }}>Loading...</p>} {status === "loading" && !liveActive && records.length === 0 && (
{status === "error" && <p style={{ color: "var(--color-error)" }}>Error: {error}</p>} <p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
{status === "ok" && ( )}
{status === "error" && !liveActive && (
<p style={{ color: "var(--color-error)" }}>Error: {error}</p>
)}
{(status === "ok" || liveActive || records.length > 0) && (
<div className="space-y-3"> <div className="space-y-3">
{data.records.map((r) => ( {records.map((r) => (
<div <div
key={`${r.type}:${r.role ?? ""}:${r.timestamp ?? 0}:${String(r.content ?? "")}`} key={`${threadId}-${r.type}-${String(r.timestamp)}-${r.role ?? ""}-${r.content ?? ""}`}
className="p-3 rounded border text-sm" className="p-3 rounded border text-sm"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }} style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
> >
@@ -90,7 +119,7 @@ export function ThreadDetail({ threadId, onBack }: Props) {
{r.role} {r.role}
</span> </span>
)} )}
{r.timestamp && ( {r.timestamp !== null && (
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}> <span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
{new Date(r.timestamp).toLocaleTimeString()} {new Date(r.timestamp).toLocaleTimeString()}
</span> </span>
@@ -106,6 +135,7 @@ export function ThreadDetail({ threadId, onBack }: Props) {
)} )}
</div> </div>
))} ))}
<div ref={recordsEndRef} aria-hidden />
</div> </div>
)} )}
</div> </div>
+64
View File
@@ -0,0 +1,64 @@
import { useCallback, useEffect, useState } from "react";
type View = "threads" | "workflows";
type HashRoute = {
view: View;
threadId: string | null;
};
function parseHash(hash: string): HashRoute {
const raw = hash.replace(/^#\/?/, "");
if (raw.startsWith("threads/")) {
const id = raw.slice("threads/".length);
if (id.length > 0) {
return { view: "threads", threadId: id };
}
}
if (raw === "workflows") {
return { view: "workflows", threadId: null };
}
return { view: "threads", threadId: null };
}
function buildHash(route: HashRoute): string {
if (route.view === "workflows") {
return "#workflows";
}
if (route.threadId !== null) {
return `#threads/${route.threadId}`;
}
return "#threads";
}
export function useHashRoute(): {
view: View;
threadId: string | null;
setView: (v: View) => void;
setThreadId: (id: string | null) => void;
} {
const [route, setRoute] = useState<HashRoute>(() => parseHash(window.location.hash));
useEffect(() => {
function onHashChange(): void {
setRoute(parseHash(window.location.hash));
}
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, []);
const navigate = useCallback((next: HashRoute) => {
const hash = buildHash(next);
window.location.hash = hash;
setRoute(next);
}, []);
const setView = useCallback((v: View) => navigate({ view: v, threadId: null }), [navigate]);
const setThreadId = useCallback(
(id: string | null) => navigate({ view: "threads", threadId: id }),
[navigate],
);
return { view: route.view, threadId: route.threadId, setView, setThreadId };
}
+161
View File
@@ -0,0 +1,161 @@
import {
type Dispatch,
type MutableRefObject,
type SetStateAction,
useEffect,
useRef,
useState,
} from "react";
import type { ThreadRecord } from "./api.ts";
export type UseSSEReturn = {
records: ThreadRecord[];
connected: boolean;
completed: boolean;
};
function isWorkflowResult(record: ThreadRecord): boolean {
return record.type === "workflow-result";
}
function parseRecord(data: string): ThreadRecord | null {
try {
return JSON.parse(data) as ThreadRecord;
} catch {
return null;
}
}
type RecordEventContext = {
cancelled: boolean;
completedRef: MutableRefObject<boolean>;
setRecords: Dispatch<SetStateAction<ThreadRecord[]>>;
setCompleted: (value: boolean) => void;
setConnected: (value: boolean) => void;
cleanupEs: () => void;
};
function handleRecordEvent(ev: Event, ctx: RecordEventContext): void {
if (ctx.cancelled) {
return;
}
const msg = ev as MessageEvent;
const raw = typeof msg.data === "string" ? msg.data : "";
const parsed = parseRecord(raw);
if (parsed === null) {
return;
}
ctx.setRecords((prev) => [...prev, parsed]);
if (!isWorkflowResult(parsed)) {
return;
}
ctx.completedRef.current = true;
ctx.setCompleted(true);
ctx.setConnected(false);
ctx.cleanupEs();
}
export function useSSE(threadId: string | null): UseSSEReturn {
const [records, setRecords] = useState<ThreadRecord[]>([]);
const [connected, setConnected] = useState(false);
const [completed, setCompleted] = useState(false);
const completedRef = useRef(false);
const reconnectAttemptsRef = useRef(0);
useEffect(() => {
if (threadId === null) {
completedRef.current = false;
reconnectAttemptsRef.current = 0;
setRecords([]);
setConnected(false);
setCompleted(false);
return;
}
const tid = threadId;
completedRef.current = false;
reconnectAttemptsRef.current = 0;
setRecords([]);
setConnected(false);
setCompleted(false);
let es: EventSource | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let cancelled = false;
function cleanupEs(): void {
if (es !== null) {
es.close();
es = null;
}
}
function scheduleReconnect(): void {
if (cancelled || completedRef.current) {
return;
}
const delayMs = Math.min(1000 * 2 ** reconnectAttemptsRef.current, 8000);
reconnectAttemptsRef.current += 1;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (!cancelled && !completedRef.current) {
connect();
}
}, delayMs);
}
function connect(): void {
if (cancelled || completedRef.current) {
return;
}
cleanupEs();
const url = `/api/threads/${encodeURIComponent(tid)}/live`;
es = new EventSource(url);
es.onopen = () => {
if (cancelled) {
return;
}
reconnectAttemptsRef.current = 0;
setConnected(true);
setRecords([]);
};
es.addEventListener("record", (ev: Event) =>
handleRecordEvent(ev, {
cancelled,
completedRef,
setRecords,
setCompleted,
setConnected,
cleanupEs,
}),
);
es.onerror = () => {
if (cancelled || completedRef.current) {
return;
}
setConnected(false);
cleanupEs();
scheduleReconnect();
};
}
connect();
return () => {
cancelled = true;
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer);
}
cleanupEs();
};
}, [threadId]);
return { records, connected, completed };
}
+1 -1
View File
@@ -6,5 +6,5 @@
"composite": true "composite": true
}, },
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-agent" }] "references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
} }
+1 -1
View File
@@ -6,5 +6,5 @@
"composite": true "composite": true
}, },
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-agent" }] "references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
} }
-1
View File
@@ -8,7 +8,6 @@
"test": "bun test" "test": "bun test"
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*" "@uncaged/workflow-runtime": "workspace:*"
} }
} }
+1 -1
View File
@@ -6,5 +6,5 @@
"composite": true "composite": true
}, },
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"references": [{ "path": "../workflow" }] "references": [{ "path": "../workflow-runtime" }]
} }
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@uncaged/workflow-cas",
"version": "0.1.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
}
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-util": "workspace:*",
"xxhashjs": "^0.2.2",
"yaml": "^2.7.1"
},
"devDependencies": {
"@types/bun": "latest"
}
}
@@ -2,7 +2,7 @@ import { Buffer } from "node:buffer";
import XXH from "xxhashjs"; import XXH from "xxhashjs";
import { encodeUint64AsCrockford } from "../util/index.js"; import { encodeUint64AsCrockford } from "@uncaged/workflow-util";
function digestToUint64(digest: { toString(radix?: number): string }): bigint { function digestToUint64(digest: { toString(radix?: number): string }): bigint {
const hex = digest.toString(16).padStart(16, "0"); const hex = digest.toString(16).padStart(16, "0");
@@ -1,4 +1,4 @@
export type { CasStore } from "@uncaged/workflow-runtime"; export type { CasStore } from "@uncaged/workflow-protocol";
export type MerkleNodeType = "content" | "step" | "thread"; export type MerkleNodeType = "content" | "step" | "thread";
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-util" }
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@uncaged/workflow-execute",
"version": "0.2.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util": "workspace:*",
"@uncaged/workflow-cas": "workspace:*",
"@uncaged/workflow-reactor": "workspace:*",
"@uncaged/workflow-register": "workspace:*",
"yaml": "^2.7.1"
},
"peerDependencies": {
"zod": "^4.0.0"
},
"devDependencies": {
"zod": "^4.0.0"
}
}
@@ -15,11 +15,11 @@ import {
getContentMerklePayload, getContentMerklePayload,
putStepMerkleNode, putStepMerkleNode,
putThreadMerkleNode, putThreadMerkleNode,
} from "../cas/index.js"; } from "@uncaged/workflow-cas";
import { resolveModel } from "../config/index.js"; import { resolveModel } from "@uncaged/workflow-register";
import { createExtract } from "../extract/index.js"; import { createExtract } from "../extract/index.js";
import { readWorkflowRegistry, type WorkflowConfig } from "../registry/index.js"; import { readWorkflowRegistry, type WorkflowConfig } from "@uncaged/workflow-register";
import { err, type LogFn, normalizeRefsField, ok, type Result } from "../util/index.js"; import { err, type LogFn, normalizeRefsField, ok, type Result } from "@uncaged/workflow-util";
import { runSupervisor } from "./supervisor.js"; import { runSupervisor } from "./supervisor.js";
import type { ExecuteThreadIo, ExecuteThreadOptions } from "./types.js"; import type { ExecuteThreadIo, ExecuteThreadOptions } from "./types.js";
@@ -1,5 +1,5 @@
import type { WorkflowCompletion } from "@uncaged/workflow-runtime"; import type { WorkflowCompletion } from "@uncaged/workflow-runtime";
import { err, normalizeRefsField, ok, type Result } from "../util/index.js"; import { err, normalizeRefsField, ok, type Result } from "@uncaged/workflow-util";
import type { ForkHistoricalStep, ForkPlan, ParsedThreadStartRecord } from "./types.js"; import type { ForkHistoricalStep, ForkPlan, ParsedThreadStartRecord } from "./types.js";
@@ -1,7 +1,7 @@
import { readdir, readFile } from "node:fs/promises"; import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { type CasStore, createCasStore } from "../cas/index.js"; import { type CasStore, createCasStore } from "@uncaged/workflow-cas";
import { err, getGlobalCasDir, ok, type Result } from "../util/index.js"; import { err, getGlobalCasDir, ok, type Result } from "@uncaged/workflow-util";
import { parseThreadDataJsonl } from "./fork-thread.js"; import { parseThreadDataJsonl } from "./fork-thread.js";
import type { GcResult } from "./types.js"; import type { GcResult } from "./types.js";
@@ -0,0 +1,85 @@
import * as z from "zod/v4";
import { resolveModel } from "@uncaged/workflow-register";
import { extractFunctionToolFromZodSchema } from "../extract/index.js";
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import type { WorkflowConfig } from "@uncaged/workflow-register";
import { err, type LogFn, ok, type Result } from "@uncaged/workflow-util";
import type { SupervisorDecision } from "./types.js";
const SUPERVISOR_RECENT_STEP_LIMIT = 12;
const SUPERVISOR_MAX_REACT_ROUNDS = 4;
const supervisorDecisionSchema = z
.object({
decision: z.enum(["continue", "stop"]),
})
.meta({
title: "supervisor_decision",
description:
'Workflow supervisor decision. "continue" when the thread is making progress; "stop" when done, looping, or stuck.',
});
type SupervisorThreadContext = Record<string, never>;
type RunSupervisorArgs = {
config: WorkflowConfig;
prompt: string;
recentSteps: readonly { role: string; summary: string }[];
logger: LogFn;
};
function buildSupervisorInput(args: RunSupervisorArgs): string {
const recent = args.recentSteps.slice(-SUPERVISOR_RECENT_STEP_LIMIT);
const stepsBlock = recent.map((s, index) => `${index + 1}. [${s.role}] ${s.summary}`).join("\n");
return `Original task:\n${args.prompt}\n\nRecent steps (oldest first):\n${stepsBlock === "" ? "(none)" : stepsBlock}`;
}
/** Calls the `supervisor` scene via {@link createThreadReactor}; opt-out when {@link resolveModel} fails (returns ok(`continue`)). */
export async function runSupervisor(
args: RunSupervisorArgs,
): Promise<Result<SupervisorDecision, string>> {
const resolved = resolveModel(args.config, "supervisor");
if (!resolved.ok) {
return ok("continue");
}
const reactor = createThreadReactor<SupervisorThreadContext>({
llm: createLlmFn(resolved.value),
maxRounds: SUPERVISOR_MAX_REACT_ROUNDS,
staticTools: [],
structuredToolFromSchema: (schema) => {
const t = extractFunctionToolFromZodSchema(schema);
return {
name: t.name,
tool: {
type: "function" as const,
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
},
};
},
systemPromptForStructuredTool: (structuredToolName) =>
`You supervise a multi-step workflow. Decide whether the thread should keep running or halt. Reply with "continue" when the thread is making progress toward the task, or "stop" when it is finished, looping, or no longer making progress. Call the ${structuredToolName} tool with JSON arguments matching the schema, or reply with only a JSON object such as {"decision":"stop"}.`,
toolHandler: async (call) => `Unknown tool: ${call.function.name}`,
});
const result = await reactor({
thread: {} as SupervisorThreadContext,
input: buildSupervisorInput(args),
schema: supervisorDecisionSchema,
});
if (!result.ok) {
args.logger("R9CW4PLM", `supervisor failed: ${result.error}`);
return err(`supervisor: ${result.error}`);
}
const decision: SupervisorDecision = result.value.decision;
args.logger("Z8KM5QWT", `supervisor says ${decision}`);
return ok(decision);
}
@@ -1,4 +1,4 @@
import { err, ok, type Result } from "../util/index.js"; import { err, ok, type Result } from "@uncaged/workflow-util";
import type { ThreadPauseGate } from "./types.js"; import type { ThreadPauseGate } from "./types.js";
@@ -1,6 +1,6 @@
import type { RoleOutput } from "@uncaged/workflow-runtime"; import type { RoleOutput } from "@uncaged/workflow-runtime";
import type { CasStore } from "../cas/index.js"; import type { CasStore } from "@uncaged/workflow-cas";
import type { Result } from "../util/index.js"; import type { Result } from "@uncaged/workflow-util";
export type SupervisorDecision = "continue" | "stop"; export type SupervisorDecision = "continue" | "stop";
@@ -2,8 +2,8 @@ import { appendFile, mkdir, unlink, writeFile } from "node:fs/promises";
import { createServer, type Socket } from "node:net"; import { createServer, type Socket } from "node:net";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import type { RoleOutput, WorkflowFn, WorkflowResult } from "@uncaged/workflow-runtime"; import type { RoleOutput, WorkflowFn, WorkflowResult } from "@uncaged/workflow-runtime";
import { ensureUncagedWorkflowSymlink, importWorkflowBundleModule } from "../bundle/index.js"; import { ensureUncagedWorkflowSymlink, importWorkflowBundleModule } from "@uncaged/workflow-register";
import { createCasStore } from "../cas/index.js"; import { createCasStore } from "@uncaged/workflow-cas";
import { import {
createLogger, createLogger,
err, err,
@@ -11,7 +11,7 @@ import {
normalizeRefsField, normalizeRefsField,
ok, ok,
type Result, type Result,
} from "../util/index.js"; } from "@uncaged/workflow-util";
import { executeThread } from "./engine.js"; import { executeThread } from "./engine.js";
import { createThreadPauseGate } from "./thread-pause-gate.js"; import { createThreadPauseGate } from "./thread-pause-gate.js";
import type { ExecuteThreadIo, PrefilledDiskStep, ThreadPauseGate } from "./types.js"; import type { ExecuteThreadIo, PrefilledDiskStep, ThreadPauseGate } from "./types.js";
@@ -0,0 +1,136 @@
import type { ExtractContext, ExtractFn, LlmProvider } from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
import { type CasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
import { extractFunctionToolFromZodSchema } from "./llm-extract.js";
export type ExtractDeps = {
cas: CasStore;
};
const MAX_REACT_ROUNDS = 10;
const CAS_GET_TOOL_DEFINITION = {
type: "function" as const,
function: {
name: "cas_get",
description:
"Read a Merkle DAG node from content-addressed storage by its hash. Returns YAML-formatted node with type, payload, and children fields.",
parameters: {
type: "object",
properties: {
hash: { type: "string", description: "The CAS hash to retrieve" },
},
required: ["hash"],
},
},
};
export type ExtractThreadContext = {
cas: CasStore;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Builds the user-side extraction prompt (thread + agent output + instruction). */
export async function buildExtractUserContent(
ctx: ExtractContext,
prompt: string,
deps: ExtractDeps,
): Promise<string> {
const lines: string[] = [];
lines.push(`## Role: ${ctx.currentRole.name}`);
lines.push(ctx.currentRole.systemPrompt);
lines.push("");
lines.push("## Task");
lines.push(ctx.start.content);
lines.push("");
if (ctx.steps.length > 0) {
lines.push("## Thread History");
for (const step of ctx.steps) {
const body = await getContentMerklePayload(deps.cas, step.contentHash);
if (body === null) {
throw new Error(`extract: missing CAS blob for step ${step.role}: ${step.contentHash}`);
}
lines.push(`### ${step.role}`);
lines.push(body);
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
lines.push("");
}
}
lines.push("## Agent Output");
lines.push(ctx.agentContent);
lines.push("");
lines.push("## Extraction Instruction");
lines.push(prompt);
return lines.join("\n");
}
/**
* Create an ExtractFn backed by an LLM provider.
*
* Internally runs a multi-turn ReAct loop with two tools (`cas_get` for traversing the
* Merkle DAG and a schema-shaped extract tool); the loop also accepts a plain-JSON
* assistant reply as a short-circuit, which covers the legacy "single" extraction path.
*/
export function createExtract(provider: LlmProvider, deps: ExtractDeps): ExtractFn {
const llm = createLlmFn(provider);
const reactor = createThreadReactor<ExtractThreadContext>({
llm,
maxRounds: MAX_REACT_ROUNDS,
staticTools: [CAS_GET_TOOL_DEFINITION],
structuredToolFromSchema: (schema) => {
const t = extractFunctionToolFromZodSchema(schema);
return {
name: t.name,
tool: {
type: "function" as const,
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
},
};
},
systemPromptForStructuredTool: (structuredToolName) =>
`You extract structured metadata from the agent output below. Use cas_get to read Merkle DAG nodes from CAS (YAML: type, payload, children) when the agent output references hashes you must traverse. When you have the complete structured object, call the ${structuredToolName} tool with JSON arguments matching the schema. You may instead reply with only a JSON object (no prose) when no tools are needed.`,
toolHandler: async (call, thread) => {
if (call.function.name !== "cas_get") {
return `Unexpected tool routed to handler: ${call.function.name}`;
}
let hash: string;
try {
const ta = JSON.parse(call.function.arguments) as unknown;
if (!isRecord(ta) || typeof ta.hash !== "string") {
return 'cas_get requires a JSON object with a string "hash" field.';
}
hash = ta.hash;
} catch {
return 'cas_get arguments were not valid JSON. Provide {"hash": "<cas-hash>"}.';
}
const blob = await thread.cas.get(hash);
return blob === null ? "null" : blob;
},
});
return async <T extends Record<string, unknown>>(
schema: z.ZodType<T>,
prompt: string,
ctx: ExtractContext,
): Promise<T> => {
const text = await buildExtractUserContent(ctx, prompt, deps);
const result = await reactor({
thread: { cas: deps.cas },
input: text,
schema,
});
if (!result.ok) {
throw new Error(`extract failed: ${result.error}`);
}
return result.value;
};
}
@@ -1,16 +1,11 @@
export { export {
buildExtractUserContent, buildExtractUserContent,
createExtract, createExtract,
type ExtractThreadContext,
} from "./extract-fn.js"; } from "./extract-fn.js";
export { export {
extractFunctionToolFromZodSchema, extractFunctionToolFromZodSchema,
llmErrorToCause, llmErrorToCause,
llmExtract, llmExtract,
} from "./llm-extract.js"; } from "./llm-extract.js";
export { reactExtract } from "./react-extract.js"; export type { ExtractFn, LlmError, LlmExtractArgs } from "./types.js";
export type {
ExtractFn,
LlmError,
LlmExtractArgs,
ReactExtractArgs,
} from "./types.js";
@@ -1,6 +1,6 @@
import * as z from "zod/v4"; import * as z from "zod/v4";
import { err, ok, type Result } from "../util/index.js"; import { err, ok, type Result } from "@uncaged/workflow-util";
import type { LlmError, LlmExtractArgs } from "./types.js"; import type { LlmError, LlmExtractArgs } from "./types.js";
@@ -1,15 +1,8 @@
import type { CasStore, LlmProvider } from "@uncaged/workflow-runtime"; import type { LlmProvider } from "@uncaged/workflow-runtime";
import type * as z from "zod/v4"; import type * as z from "zod/v4";
export type { ExtractFn } from "@uncaged/workflow-runtime"; export type { ExtractFn } from "@uncaged/workflow-runtime";
export type ReactExtractArgs<T extends Record<string, unknown>> = {
text: string;
schema: z.ZodType<T>;
provider: LlmProvider;
cas: CasStore;
};
export type LlmExtractArgs<T> = { export type LlmExtractArgs<T> = {
text: string; text: string;
schema: z.ZodType<T>; schema: z.ZodType<T>;
+35
View File
@@ -0,0 +1,35 @@
export { createWorkflow } from "./engine/create-workflow.js";
export { executeThread } from "./engine/engine.js";
export {
buildForkPlan,
parseThreadDataJsonl,
selectForkHistoricalSteps,
tryParseRoleStepRecord,
tryParseWorkflowResultRecord,
} from "./engine/fork-thread.js";
export { garbageCollectCas } from "./engine/gc.js";
export { createThreadPauseGate } from "./engine/thread-pause-gate.js";
export type {
ExecuteThreadIo,
ExecuteThreadOptions,
ForkHistoricalStep,
ForkPlan,
GcResult,
ParsedThreadStartRecord,
PrefilledDiskStep,
SupervisorDecision,
ThreadPauseGate,
} from "./engine/types.js";
export { getWorkerHostScriptPath } from "./engine/worker-entry-path.js";
export {
buildExtractUserContent,
createExtract,
type ExtractThreadContext,
} from "./extract/index.js";
export {
extractFunctionToolFromZodSchema,
llmErrorToCause,
llmExtract,
} from "./extract/index.js";
export type { ExtractFn, LlmError, LlmExtractArgs } from "./extract/index.js";
export { workflowAsAgent, type WorkflowAsAgentOptions } from "./workflow-as-agent.js";
@@ -1,17 +1,17 @@
import { join } from "node:path"; import { join } from "node:path";
import type { AgentContext, AgentFn } from "@uncaged/workflow-runtime"; import type { AgentContext, AgentFn } from "@uncaged/workflow-runtime";
import { extractBundleExports } from "./bundle/index.js"; import { extractBundleExports } from "@uncaged/workflow-register";
import { createCasStore } from "./cas/index.js"; import { createCasStore } from "@uncaged/workflow-cas";
import type { ExecuteThreadIo } from "./engine/index.js"; import type { ExecuteThreadIo } from "./engine/index.js";
import { executeThread } from "./engine/index.js"; import { executeThread } from "./engine/index.js";
import type { WorkflowConfig } from "./registry/index.js"; import type { WorkflowConfig } from "@uncaged/workflow-register";
import { getRegisteredWorkflow, readWorkflowRegistry } from "./registry/index.js"; import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
import { import {
createLogger, createLogger,
generateUlid, generateUlid,
getDefaultWorkflowStorageRoot, getDefaultWorkflowStorageRoot,
getGlobalCasDir, getGlobalCasDir,
} from "./util/index.js"; } from "@uncaged/workflow-util";
const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3; const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-runtime" },
{ "path": "../workflow-util" },
{ "path": "../workflow-cas" },
{ "path": "../workflow-reactor" },
{ "path": "../workflow-register" }
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@uncaged/workflow-protocol",
"version": "0.1.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
}
},
"peerDependencies": {
"zod": "^4.0.0"
},
"devDependencies": {
"zod": "^4.0.0",
"typescript": "^5.8.3"
}
}
+40
View File
@@ -0,0 +1,40 @@
// ── Types ──────────────────────────────────────────────────────────
export type {
Result,
CasStore,
WorkflowRoleSchema,
WorkflowRoleDescriptor,
WorkflowDescriptor,
RoleMeta,
RoleOutput,
StartStep,
RoleStep,
ThreadContext,
ModeratorContext,
AgentContext,
ExtractContext,
WorkflowCompletion,
WorkflowResult,
LlmProvider,
ProviderConfig,
ResolvedModel,
WorkflowConfig,
ExtractFn,
AgentFn,
AgentBinding,
WorkflowRuntime,
WorkflowFn,
RoleDefinition,
Moderator,
WorkflowDefinition,
AdvanceOutcome,
} from "./types.js";
// ── Constants ──────────────────────────────────────────────────────
export { START, END } from "./types.js";
// ── Constructor functions ──────────────────────────────────────────
export { ok, err } from "./result.js";
@@ -1,9 +1,9 @@
import type { Result } from "./types.js"; import type { Result } from "./types.js";
export function ok<T>(value: T): Result<T, never> { export function ok<T>(value: T): Result<T, never> {
return { ok: true, value }; return { ok: true, value };
} }
export function err<E>(error: E): Result<never, E> { export function err<E>(error: E): Result<never, E> {
return { ok: false, error }; return { ok: false, error };
} }
+167
View File
@@ -0,0 +1,167 @@
import type * as z from "zod/v4";
// ── Constants ──────────────────────────────────────────────────────
export const START = "__start__" as const;
export const END = "__end__" as const;
// ── Result ─────────────────────────────────────────────────────────
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
// ── CAS ────────────────────────────────────────────────────────────
export type CasStore = {
put(content: string): Promise<string>;
get(hash: string): Promise<string | null>;
delete(hash: string): Promise<void>;
list(): Promise<string[]>;
};
// ── Workflow Descriptor ────────────────────────────────────────────
export type WorkflowRoleSchema = Record<string, unknown>;
export type WorkflowRoleDescriptor = {
description: string;
schema: WorkflowRoleSchema;
};
export type WorkflowDescriptor = {
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
};
// ── Role & Thread ──────────────────────────────────────────────────
export type RoleMeta = Record<string, Record<string, unknown>>;
export type RoleOutput = {
role: string;
contentHash: string;
meta: Record<string, unknown>;
refs: string[];
};
export type StartStep = {
role: typeof START;
content: string;
meta: { maxRounds: number };
timestamp: number;
};
export type RoleStep<M extends RoleMeta> = {
[K in keyof M & string]: {
role: K;
meta: M[K];
contentHash: string;
refs: string[];
timestamp: number;
};
}[keyof M & string];
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
threadId: string;
depth: number;
start: StartStep;
steps: RoleStep<M>[];
};
export type ModeratorContext<M extends RoleMeta = RoleMeta> = ThreadContext<M>;
export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> & {
currentRole: {
name: string;
systemPrompt: string;
};
};
export type ExtractContext<M extends RoleMeta = RoleMeta> = AgentContext<M> & {
agentContent: string;
};
// ── Workflow Completion ────────────────────────────────────────────
export type WorkflowCompletion = {
returnCode: number;
summary: string;
};
export type WorkflowResult = WorkflowCompletion & {
rootHash: string;
};
// ── LLM Provider ───────────────────────────────────────────────────
export type LlmProvider = {
baseUrl: string;
apiKey: string;
model: string;
};
export type ProviderConfig = {
baseUrl: string;
apiKey: string;
};
export type ResolvedModel = {
baseUrl: string;
apiKey: string;
model: string;
};
export type WorkflowConfig = {
maxDepth: number;
supervisorInterval: number;
providers: Record<string, ProviderConfig>;
models: Record<string, string>;
};
// ── Functions ──────────────────────────────────────────────────────
export type ExtractFn = <T extends Record<string, unknown>>(
schema: z.ZodType<T>,
prompt: string,
ctx: ExtractContext,
) => Promise<T>;
export type AgentFn = (ctx: AgentContext) => Promise<string>;
export type AgentBinding = {
agent: AgentFn;
overrides: Partial<Record<string, AgentFn>> | null;
};
// ── Workflow Runtime & Definition ──────────────────────────────────
export type WorkflowRuntime = {
cas: CasStore;
extract: ExtractFn;
};
export type WorkflowFn = (
thread: ThreadContext,
runtime: WorkflowRuntime,
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
export type RoleDefinition<Meta extends Record<string, unknown>> = {
description: string;
systemPrompt: string;
extractPrompt: string;
schema: z.ZodType<Meta>;
extractRefs: ((meta: Meta) => string[]) | null;
};
export type Moderator<M extends RoleMeta> = (
ctx: ModeratorContext<M>,
) => (keyof M & string) | typeof END;
export type WorkflowDefinition<M extends RoleMeta> = {
description: string;
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
moderator: Moderator<M>;
};
export type AdvanceOutcome<M extends RoleMeta> =
| { kind: "complete"; completion: WorkflowCompletion }
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@uncaged/workflow-reactor",
"version": "0.1.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
}
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*"
},
"peerDependencies": {
"zod": "^4.0.0"
},
"devDependencies": {
"zod": "^4.0.0",
"typescript": "^5.8.3"
}
}
+12
View File
@@ -0,0 +1,12 @@
export { createLlmFn } from "./llm-fn.js";
export { createThreadReactor } from "./thread-reactor.js";
export type {
ChatMessage,
LlmFn,
StructuredToolSpec,
ThreadReactorConfig,
ThreadReactorFn,
ThreadReactorInvokeArgs,
ToolCall,
ToolDefinition,
} from "./types.js";
+48
View File
@@ -0,0 +1,48 @@
import type { LlmProvider } from "@uncaged/workflow-protocol";
import { err, ok } from "@uncaged/workflow-protocol";
import type { ChatMessage, LlmFn, ToolDefinition } from "./types.js";
function chatCompletionsUrl(baseUrl: string): string {
const trimmed = baseUrl.replace(/\/+$/, "");
return `${trimmed}/chat/completions`;
}
/**
* Wraps provider credentials into an {@link LlmFn}: single POST to chat/completions,
* returns raw JSON body text or a {@link Result} error. Callers parse assistant messages.
*/
export function createLlmFn(provider: LlmProvider): LlmFn {
return async ({
messages,
tools,
}: {
messages: ChatMessage[];
tools: readonly ToolDefinition[];
}) => {
try {
const response = await fetch(chatCompletionsUrl(provider.baseUrl), {
method: "POST",
headers: {
Authorization: `Bearer ${provider.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: provider.model,
messages,
tools,
tool_choice: "auto",
}),
});
const responseText = await response.text();
if (!response.ok) {
return err(`http_error:${String(response.status)}:${responseText.slice(0, 4000)}`);
}
return ok(responseText);
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
return err(`network_error:${message}`);
}
};
}
@@ -0,0 +1,317 @@
import type * as z from "zod/v4";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
import type {
ChatMessage,
StructuredToolSpec,
ThreadReactorConfig,
ThreadReactorFn,
ToolCall,
ToolDefinition,
} from "./types.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function tryParseJsonContent(content: string): unknown | null {
const trimmed = content.trim();
const fenceMatch = /^```(?:json)?\s*([\s\S]*?)```$/m.exec(trimmed);
const payload = fenceMatch !== null ? fenceMatch[1].trim() : trimmed;
try {
return JSON.parse(payload) as unknown;
} catch {
return null;
}
}
function firstAssistantMessage(responseText: string): Result<Record<string, unknown>, string> {
let parsed: unknown;
try {
parsed = JSON.parse(responseText) as unknown;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
return err(`invalid_response_json:${message}`);
}
if (!isRecord(parsed)) {
return err("invalid_response_top_level");
}
const choices = parsed.choices;
if (!Array.isArray(choices) || choices.length === 0) {
return err("no_choices_in_response");
}
const firstChoice = choices[0];
if (!isRecord(firstChoice)) {
return err("invalid_choice");
}
const messageObj = firstChoice.message;
if (!isRecord(messageObj)) {
return err("invalid_message");
}
return ok(messageObj);
}
function normalizeToolCalls(toolCallsRaw: unknown[]): Result<ToolCall[], string> {
const toolCalls: ToolCall[] = [];
for (const tc of toolCallsRaw) {
if (!isRecord(tc)) {
return err("invalid_tool_call");
}
const id = tc.id;
const tcType = tc.type;
const fn = tc.function;
if (typeof id !== "string" || tcType !== "function" || !isRecord(fn)) {
return err("invalid_tool_call_shape");
}
const name = fn.name;
const argumentsStr = fn.arguments;
if (typeof name !== "string" || typeof argumentsStr !== "string") {
return err("invalid_tool_call_function");
}
toolCalls.push({ id, type: "function", function: { name, arguments: argumentsStr } });
}
return ok(toolCalls);
}
type AssistantTurn<T> =
| { kind: "plain_json"; value: T }
| { kind: "tool_calls"; calls: ToolCall[]; assistantContent: string | null };
type AssistantTurnOrCorrection<T> =
| AssistantTurn<T>
| { kind: "plain_json_invalid"; rawContent: string; correction: string };
function classifyAssistantTurn<T>(
messageObj: Record<string, unknown>,
schema: z.ZodType<T>,
structuredToolName: string,
): Result<AssistantTurnOrCorrection<T>, string> {
const toolCallsRaw = messageObj.tool_calls;
if (!Array.isArray(toolCallsRaw) || toolCallsRaw.length === 0) {
const content = messageObj.content;
if (typeof content !== "string") {
return err("no_tool_calls_and_no_string_content");
}
const jsonParsed = tryParseJsonContent(content);
if (jsonParsed === null) {
return ok({
kind: "plain_json_invalid",
rawContent: content,
correction: `Your previous reply was not valid JSON and contained no tool calls. Reply with a single JSON object that matches the schema, or call the ${structuredToolName} tool with the structured arguments.`,
});
}
const validated = schema.safeParse(jsonParsed);
if (!validated.success) {
return ok({
kind: "plain_json_invalid",
rawContent: content,
correction: `Your previous JSON reply did not satisfy the schema: ${validated.error.message}. Reply again with a JSON object that matches the schema, or call the ${structuredToolName} tool with the structured arguments.`,
});
}
return ok({ kind: "plain_json", value: validated.data });
}
const callsResult = normalizeToolCalls(toolCallsRaw);
if (!callsResult.ok) {
return err(callsResult.error);
}
const assistantContent = messageObj.content;
return ok({
kind: "tool_calls",
calls: callsResult.value,
assistantContent: typeof assistantContent === "string" ? assistantContent : null,
});
}
function toolNamesFromDefinitions(tools: readonly { function: { name: string } }[]): Set<string> {
return new Set(tools.map((t) => t.function.name));
}
function appendStructuredToolResult<T>(
tc: ToolCall,
schema: z.ZodType<T>,
messages: ChatMessage[],
): T | null {
let parsedArgs: unknown;
try {
parsedArgs = JSON.parse(tc.function.arguments) as unknown;
} catch {
messages.push({
role: "tool",
tool_call_id: tc.id,
content:
"Tool arguments were not valid JSON. Provide valid JSON object arguments matching the schema.",
});
return null;
}
const validated = schema.safeParse(parsedArgs);
if (!validated.success) {
messages.push({
role: "tool",
tool_call_id: tc.id,
content: `Schema validation failed: ${validated.error.message}. Fix the arguments and call the tool again with a JSON object that matches the schema.`,
});
return null;
}
messages.push({
role: "tool",
tool_call_id: tc.id,
content: '{"ok":true}',
});
return validated.data;
}
async function dispatchToolCall<T, TThread>(
tc: ToolCall,
spec: StructuredToolSpec,
knownNames: Set<string>,
schema: z.ZodType<T>,
thread: TThread,
toolHandler: ThreadReactorConfig<TThread>["toolHandler"],
messages: ChatMessage[],
): Promise<T | null> {
if (!knownNames.has(tc.function.name)) {
messages.push({
role: "tool",
tool_call_id: tc.id,
content: `Unknown tool: ${tc.function.name}. Use one of the declared tools only.`,
});
return null;
}
if (tc.function.name === spec.name) {
return appendStructuredToolResult(tc, schema, messages);
}
let toolContent: string;
try {
toolContent = await toolHandler(tc, thread);
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
toolContent = `Tool execution failed: ${message}`;
}
messages.push({
role: "tool",
tool_call_id: tc.id,
content: toolContent,
});
return null;
}
async function resolveToolCallRound<T, TThread>(
turn: Extract<AssistantTurn<T>, { kind: "tool_calls" }>,
spec: StructuredToolSpec,
knownNames: Set<string>,
schema: z.ZodType<T>,
thread: TThread,
toolHandler: ThreadReactorConfig<TThread>["toolHandler"],
messages: ChatMessage[],
): Promise<Result<T, string> | null> {
messages.push({
role: "assistant",
content: turn.assistantContent,
tool_calls: turn.calls,
});
let extractedRound: T | null = null;
for (const tc of turn.calls) {
const extracted = await dispatchToolCall(
tc,
spec,
knownNames,
schema,
thread,
toolHandler,
messages,
);
if (extracted !== null) {
extractedRound = extracted;
}
}
if (extractedRound !== null) {
return ok(extractedRound);
}
return null;
}
async function runOneReactRound<T, TThread>(
config: ThreadReactorConfig<TThread>,
args: { thread: TThread; schema: z.ZodType<T> },
tools: readonly ToolDefinition[],
knownNames: Set<string>,
spec: StructuredToolSpec,
messages: ChatMessage[],
): Promise<Result<T, string> | null> {
const bodyResult = await config.llm({ messages, tools });
if (!bodyResult.ok) {
return bodyResult;
}
const msgResult = firstAssistantMessage(bodyResult.value);
if (!msgResult.ok) {
return msgResult;
}
const classified = classifyAssistantTurn(msgResult.value, args.schema, spec.name);
if (!classified.ok) {
return classified;
}
const turn = classified.value;
if (turn.kind === "plain_json") {
return ok(turn.value);
}
if (turn.kind === "plain_json_invalid") {
messages.push({ role: "assistant", content: turn.rawContent });
messages.push({ role: "user", content: turn.correction });
return null;
}
return resolveToolCallRound(
turn,
spec,
knownNames,
args.schema,
args.thread,
config.toolHandler,
messages,
);
}
/**
* Generic ReAct loop: LLM round-trips with tools until structured output validates,
* plain JSON matches schema, or {@link ThreadReactorConfig.maxRounds} is exceeded.
*/
export function createThreadReactor<TThread>(
config: ThreadReactorConfig<TThread>,
): ThreadReactorFn<TThread> {
return async <T>(args: {
thread: TThread;
input: string;
schema: z.ZodType<T>;
}): Promise<Result<T, string>> => {
const spec = config.structuredToolFromSchema(args.schema);
const tools = [...config.staticTools, spec.tool];
const knownNames = toolNamesFromDefinitions(tools);
const systemPrompt = config.systemPromptForStructuredTool(spec.name);
const messages: ChatMessage[] = [
{ role: "system", content: systemPrompt },
{ role: "user", content: args.input },
];
for (let round = 0; round < config.maxRounds; round++) {
const step = await runOneReactRound(
config,
{ thread: args.thread, schema: args.schema },
tools,
knownNames,
spec,
messages,
);
if (step !== null) {
return step;
}
}
return err("max_react_rounds_exceeded");
};
}
+62
View File
@@ -0,0 +1,62 @@
import type * as z from "zod/v4";
import type { Result } from "@uncaged/workflow-protocol";
export type ToolCall = {
id: string;
type: "function";
function: { name: string; arguments: string };
};
export type ToolDefinition = {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
};
export type ChatMessage =
| { role: "system"; content: string }
| { role: "user"; content: string }
| {
role: "assistant";
content: string | null;
tool_calls: ToolCall[];
}
| { role: "assistant"; content: string }
| { role: "tool"; tool_call_id: string; content: string };
export type LlmFn = (input: {
messages: ChatMessage[];
tools: readonly ToolDefinition[];
}) => Promise<Result<string, string>>;
/** Structured tool derived from the per-invocation Zod schema (e.g. extract tool). */
export type StructuredToolSpec = {
name: string;
tool: ToolDefinition;
};
export type ThreadReactorConfig<TThread> = {
llm: LlmFn;
/** Static tools (e.g. cas_get); structured tool is appended per invocation. */
staticTools: readonly ToolDefinition[];
/** Builds the schema-shaped tool and its OpenAI name for this invocation. */
structuredToolFromSchema: (schema: z.ZodType<unknown>) => StructuredToolSpec;
/** System prompt for this run; include the structured tool name for cache stability per schema. */
systemPromptForStructuredTool: (structuredToolName: string) => string;
toolHandler: (call: ToolCall, thread: TThread) => Promise<string>;
maxRounds: number;
};
export type ThreadReactorInvokeArgs<TThread, T> = {
thread: TThread;
input: string;
schema: z.ZodType<T>;
};
export type ThreadReactorFn<TThread> = <T>(
args: ThreadReactorInvokeArgs<TThread, T>,
) => Promise<Result<T, string>>;
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [
{ "path": "../workflow-protocol" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@uncaged/workflow-register",
"version": "0.1.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
}
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-util": "workspace:*"
},
"peerDependencies": {
"acorn": "^8.0.0",
"yaml": "^2.0.0",
"zod": "^4.0.0"
},
"devDependencies": {
"acorn": "^8.14.1",
"yaml": "^2.7.1",
"zod": "^4.0.0",
"typescript": "^5.8.3"
}
}
@@ -1,4 +1,4 @@
import type { RoleMeta, WorkflowDefinition } from "@uncaged/workflow-runtime"; import type { RoleMeta, WorkflowDefinition } from "@uncaged/workflow-protocol";
import * as z from "zod/v4"; import * as z from "zod/v4";
import type { WorkflowDescriptor, WorkflowRoleSchema } from "./types.js"; import type { WorkflowDescriptor, WorkflowRoleSchema } from "./types.js";
@@ -12,7 +12,7 @@ import type {
} from "acorn"; } from "acorn";
import * as acorn from "acorn"; import * as acorn from "acorn";
import { err, ok, type Result } from "../util/index.js"; import { err, ok, type Result } from "@uncaged/workflow-util";
import type { WorkflowBundleValidationInput } from "./types.js"; import type { WorkflowBundleValidationInput } from "./types.js";
@@ -38,7 +38,11 @@ function isAllowedImportSpecifier(spec: string): boolean {
if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("file:")) { if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("file:")) {
return false; return false;
} }
if (spec === "@uncaged/workflow" || spec === "@uncaged/workflow-runtime") { if (
spec === "@uncaged/workflow" ||
spec === "@uncaged/workflow-runtime" ||
spec === "@uncaged/workflow-cas"
) {
return true; return true;
} }
return isBuiltin(spec); return isBuiltin(spec);
@@ -294,7 +298,7 @@ function validateImportDeclaration(node: ImportDeclaration): string | null {
return "only static string import specifiers are allowed"; return "only static string import specifiers are allowed";
} }
if (!isAllowedImportSpecifier(spec)) { if (!isAllowedImportSpecifier(spec)) {
return `disallowed import specifier "${spec}" (only Node built-ins and "@uncaged/workflow" are allowed)`; return `disallowed import specifier "${spec}" (only Node built-ins and @uncaged/workflow-* packages are allowed)`;
} }
return null; return null;
} }
@@ -309,7 +313,7 @@ function validateExportSource(
return staticMessage; return staticMessage;
} }
if (!isAllowedImportSpecifier(spec)) { if (!isAllowedImportSpecifier(spec)) {
return `${disallowedPrefix} "${spec}" (only Node built-ins and "@uncaged/workflow" are allowed)`; return `${disallowedPrefix} "${spec}" (only Node built-ins and @uncaged/workflow-* packages are allowed)`;
} }
return null; return null;
} }
@@ -0,0 +1,56 @@
import { mkdir, readlink, symlink, unlink } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
/** This module lives in `@uncaged/workflow-register/src/bundle`; grandparent dir is the package root. */
function installedWorkflowPackageDir(): string {
return fileURLToPath(new URL("../..", import.meta.url));
}
/**
* Resolve sibling @uncaged/* package directory relative to workflow-register.
* In a monorepo workspace layout the sibling packages live next to workflow-register.
*/
function siblingPackageDir(packageName: string): string {
const registerRoot = installedWorkflowPackageDir();
return path.resolve(registerRoot, "..", packageName);
}
async function ensureSymlink(linkDir: string, name: string, target: string): Promise<void> {
const linkPath = path.join(linkDir, name);
await mkdir(linkDir, { recursive: true });
try {
const existing = await readlink(linkPath);
const normalizedExisting = path.resolve(linkDir, existing);
if (normalizedExisting === target) {
return;
}
await unlink(linkPath);
} catch (e) {
const errObj = e as NodeJS.ErrnoException;
if (errObj.code !== "ENOENT" && errObj.code !== "EINVAL") {
throw e;
}
}
const linkType = process.platform === "win32" ? "junction" : "dir";
await symlink(target, linkPath, linkType);
}
/**
* Ensures `<storageRoot>/node_modules/@uncaged/*` symlinks point at installed packages
* so workflow bundles loaded from `<storageRoot>/bundles/*.esm.js` can resolve their imports.
*/
export async function ensureUncagedWorkflowSymlink(storageRoot: string): Promise<void> {
const linkDir = path.join(storageRoot, "node_modules", "@uncaged");
const packages = [
{ name: "workflow", dir: siblingPackageDir("workflow") },
{ name: "workflow-runtime", dir: siblingPackageDir("workflow-runtime") },
{ name: "workflow-cas", dir: siblingPackageDir("workflow-cas") },
{ name: "workflow-protocol", dir: siblingPackageDir("workflow-protocol") },
];
for (const pkg of packages) {
await ensureSymlink(linkDir, pkg.name, pkg.dir);
}
}
@@ -1,5 +1,5 @@
import type { WorkflowFn } from "@uncaged/workflow-runtime"; import type { WorkflowFn } from "@uncaged/workflow-protocol";
import { err, ok, type Result } from "../util/index.js"; import { err, ok, type Result } from "@uncaged/workflow-util";
import { importWorkflowBundleModule } from "./bundle-import-env.js"; import { importWorkflowBundleModule } from "./bundle-import-env.js";
import { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js"; import { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
import type { ExtractBundleExportsOptions, ExtractedBundleExports } from "./types.js"; import type { ExtractBundleExportsOptions, ExtractedBundleExports } from "./types.js";
@@ -1,10 +1,11 @@
import type { WorkflowDescriptor, WorkflowFn } from "@uncaged/workflow-runtime"; import type { WorkflowDescriptor, WorkflowFn } from "@uncaged/workflow-protocol";
export type { export type {
WorkflowDescriptor, WorkflowDescriptor,
WorkflowRoleDescriptor, WorkflowRoleDescriptor,
WorkflowRoleSchema, WorkflowRoleSchema,
} from "@uncaged/workflow-runtime"; WorkflowFn,
} from "@uncaged/workflow-protocol";
export type WorkflowBundleValidationInput = { export type WorkflowBundleValidationInput = {
/** Absolute or relative path (used for `.esm.js` suffix checks). */ /** Absolute or relative path (used for `.esm.js` suffix checks). */

Some files were not shown because too many files have changed in this diff Show More