Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6306b23a9f | |||
| 6bb8cf8315 | |||
| 93b7947d7c | |||
| 9584a86fb7 | |||
| defc0afc27 | |||
| 9f6633d5bf | |||
| 7dadf874e1 | |||
| ba90214af6 | |||
| 5bbac3e4f7 | |||
| 131021b1a7 | |||
| e42555fd9c | |||
| 3a26eb28e5 | |||
| c1a17b707c | |||
| 4ea1e0d8a4 | |||
| b1a9d2ec3f | |||
| 2b8707a706 | |||
| 241bfbf6d9 | |||
| 40530d757e | |||
| 0f3661b566 | |||
| 9c44c709e9 | |||
| 8892ab9978 | |||
| 7ec86d82a3 | |||
| f728b36e8d | |||
| 3431d3070b | |||
| 576df067d4 | |||
| a46a225d04 | |||
| f74b482cc0 | |||
| 89abfdc257 | |||
| 77e395b913 | |||
| b65a006d45 | |||
| 5994548f0b | |||
| 0871ae54ea | |||
| 9576d69032 | |||
| 64dadf114d | |||
| baaa1d1dc8 | |||
| 3074cd5f0c | |||
| 15edc99c72 | |||
| 153178c545 | |||
| fac215bd21 | |||
| 9822e68c55 | |||
| 764b73209e | |||
| e7987c4cd7 | |||
| 942ff4b1a4 | |||
| f5977c46c6 | |||
| 71ccf8d03c | |||
| 510b49287a | |||
| bb6b309efa | |||
| 56db22a908 | |||
| 2a1b7b0aeb | |||
| d037eca4ae | |||
| b9d543a465 | |||
| 07f52594d1 | |||
| c7b426ff5a | |||
| 4582274ba4 | |||
| d140801337 | |||
| 4563f1bb5e | |||
| 59b7e89028 | |||
| 019d8c1ee9 | |||
| 5e783e7a24 | |||
| a450a88b16 | |||
| 5b47317cef | |||
| 3384c38d02 | |||
| c2c6fc5304 | |||
| 94f725c50b |
@@ -2,16 +2,10 @@
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [
|
||||
[
|
||||
"@uncaged/*"
|
||||
]
|
||||
],
|
||||
"fixed": [["@uncaged/*"]],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": [
|
||||
"@uncaged/workflow-dashboard"
|
||||
]
|
||||
"ignore": ["@uncaged/workflow-dashboard"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-util": patch
|
||||
---
|
||||
|
||||
Replace optionalEnv/requireEnv with unified env(name, fallback) API
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-protocol": patch
|
||||
---
|
||||
|
||||
fix: correct internal dependency versions for prerelease
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-util-agent": patch
|
||||
---
|
||||
|
||||
fix: include create-agent-adapter.ts in published src
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-protocol": patch
|
||||
---
|
||||
|
||||
fix: use npm publish with pinned deps instead of bun publish (workspace:^ resolution bug)
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"mode": "pre",
|
||||
"tag": "alpha",
|
||||
"initialVersions": {
|
||||
"@uncaged/cli-workflow": "0.4.5",
|
||||
"@uncaged/workflow-agent-cursor": "0.4.5",
|
||||
"@uncaged/workflow-agent-hermes": "0.4.5",
|
||||
"@uncaged/workflow-agent-llm": "0.4.5",
|
||||
"@uncaged/workflow-agent-react": "0.4.5",
|
||||
"@uncaged/workflow-cas": "0.4.5",
|
||||
"@uncaged/workflow-dashboard": "0.1.0",
|
||||
"@uncaged/workflow-execute": "0.4.5",
|
||||
"@uncaged/workflow-gateway": "0.4.5",
|
||||
"@uncaged/workflow-protocol": "0.4.5",
|
||||
"@uncaged/workflow-reactor": "0.4.5",
|
||||
"@uncaged/workflow-register": "0.4.5",
|
||||
"@uncaged/workflow-runtime": "0.4.5",
|
||||
"@uncaged/workflow-template-develop": "0.4.5",
|
||||
"@uncaged/workflow-template-solve-issue": "0.4.5",
|
||||
"@uncaged/workflow-util": "0.4.5",
|
||||
"@uncaged/workflow-util-agent": "0.4.5"
|
||||
},
|
||||
"changesets": [
|
||||
"env-api-unify",
|
||||
"fix-internal-deps",
|
||||
"fix-publish-src",
|
||||
"fix-workspace-deps",
|
||||
"rfc-252-agent-fn"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-protocol": minor
|
||||
---
|
||||
|
||||
feat: AgentFn<Opt> type boundary and createAgentAdapter bridging function (RFC #252)
|
||||
@@ -0,0 +1,40 @@
|
||||
# ──────────────────────────────────────────────
|
||||
# Workflow Engine — Environment Variables
|
||||
# ──────────────────────────────────────────────
|
||||
# Copy this file to .env and fill in the values.
|
||||
|
||||
# ── Cursor Agent ──
|
||||
|
||||
# CLI command to invoke the Cursor agent (required for develop workflow)
|
||||
WORKFLOW_CURSOR_COMMAND=
|
||||
|
||||
# Model override for Cursor agent
|
||||
WORKFLOW_CURSOR_MODEL=
|
||||
|
||||
# Timeout in milliseconds for Cursor agent operations
|
||||
WORKFLOW_CURSOR_TIMEOUT=
|
||||
|
||||
# ── Hermes Agent (used by develop tester/committer + solve-issue) ──
|
||||
|
||||
# CLI command to invoke the Hermes agent (absolute path required)
|
||||
WORKFLOW_HERMES_COMMAND=
|
||||
|
||||
# Model override for Hermes agent
|
||||
WORKFLOW_HERMES_MODEL=
|
||||
|
||||
# Timeout in milliseconds for Hermes agent operations
|
||||
WORKFLOW_HERMES_TIMEOUT=
|
||||
|
||||
# ── Storage ──
|
||||
|
||||
# Override the workflow storage root directory
|
||||
# Default: ~/.uncaged/workflow
|
||||
WORKFLOW_STORAGE_ROOT=
|
||||
|
||||
# Gateway secret for the serve command
|
||||
WORKFLOW_DASHBOARD_SECRET=
|
||||
|
||||
# ── Display ──
|
||||
|
||||
# Set to any value to disable colored output
|
||||
# NO_COLOR=1
|
||||
+7
-3
@@ -1,6 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# pre-push hook: typecheck + biome + lint-log-tags
|
||||
set -euo pipefail
|
||||
echo "🔍 pre-push: running checks..."
|
||||
|
||||
echo "🔍 Running check (tsc + biome + lint-log-tags)..."
|
||||
bun run check
|
||||
echo "✅ pre-push: all checks passed"
|
||||
|
||||
echo "🧪 Running tests..."
|
||||
bun run test
|
||||
|
||||
echo "✅ All checks passed!"
|
||||
|
||||
@@ -6,3 +6,6 @@ tsconfig.tsbuildinfo
|
||||
.npmrc
|
||||
|
||||
bunfig.toml
|
||||
xiaoju/
|
||||
solve-issue-entry.ts
|
||||
packages/workflow-template-develop/develop.esm.js
|
||||
|
||||
+8
-2
@@ -1,7 +1,13 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
||||
"files": {
|
||||
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/dist",
|
||||
"!**/node_modules",
|
||||
"!packages/workflow/workflow",
|
||||
"!xiaoju/scripts/bundle.ts"
|
||||
]
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"formatter": {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createCursorAgent } from "./packages/workflow-agent-cursor/src/index.js";
|
||||
import { createWorkflow } from "./packages/workflow-runtime/src/create-workflow.js";
|
||||
import {
|
||||
buildDevelopDescriptor,
|
||||
developWorkflowDefinition,
|
||||
} from "./packages/workflow-template-develop/src/index.js";
|
||||
|
||||
const agent = createCursorAgent({
|
||||
command: "/home/azureuser/.local/bin/cursor-agent",
|
||||
model: "auto",
|
||||
timeout: 300_000,
|
||||
workspace: null,
|
||||
});
|
||||
|
||||
export const descriptor = buildDevelopDescriptor();
|
||||
export const run = createWorkflow(developWorkflowDefinition, { adapter: agent, overrides: null });
|
||||
@@ -1,5 +1,71 @@
|
||||
# @uncaged/cli-workflow
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [f74b482]
|
||||
- Updated dependencies [f74b482]
|
||||
- @uncaged/workflow-util@0.5.0-alpha.4
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.4
|
||||
- @uncaged/workflow-execute@0.5.0-alpha.4
|
||||
- @uncaged/workflow-gateway@0.5.0-alpha.4
|
||||
- @uncaged/workflow-register@0.5.0-alpha.4
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.3
|
||||
- @uncaged/workflow-execute@0.5.0-alpha.3
|
||||
- @uncaged/workflow-gateway@0.5.0-alpha.3
|
||||
- @uncaged/workflow-register@0.5.0-alpha.3
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||
- @uncaged/workflow-util@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.2
|
||||
- @uncaged/workflow-execute@0.5.0-alpha.2
|
||||
- @uncaged/workflow-gateway@0.5.0-alpha.2
|
||||
- @uncaged/workflow-register@0.5.0-alpha.2
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||
- @uncaged/workflow-util@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.1
|
||||
- @uncaged/workflow-execute@0.5.0-alpha.1
|
||||
- @uncaged/workflow-gateway@0.5.0-alpha.1
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||
- @uncaged/workflow-register@0.5.0-alpha.1
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||
- @uncaged/workflow-util@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.0
|
||||
- @uncaged/workflow-execute@0.5.0-alpha.0
|
||||
- @uncaged/workflow-register@0.5.0-alpha.0
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||
- @uncaged/workflow-util@0.5.0-alpha.0
|
||||
- @uncaged/workflow-gateway@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -20,9 +20,6 @@ import { addCliArgs } from "./bundle-fixture.js";
|
||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {}, graph: { edges: [] } };
|
||||
`;
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
`;
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||
}
|
||||
@@ -52,12 +49,12 @@ describe("cli workflow commands", () => {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}import fs from "node:fs";
|
||||
`${fixtureDescriptor}import fs from "node:fs";
|
||||
|
||||
export const run = async function* (input, options) {
|
||||
fs.existsSync(".");
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, input.prompt);
|
||||
const h = await cas.put(input.prompt);
|
||||
yield { role: "noop", contentHash: h, meta: { done: true }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
}
|
||||
@@ -155,10 +152,9 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
|
||||
},
|
||||
graph: { edges: [] },
|
||||
};
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, input.prompt);
|
||||
const h = await cas.put( input.prompt);
|
||||
yield { role: "greeter", contentHash: h, meta: { greeting: "hi" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "ok" };
|
||||
};
|
||||
@@ -197,9 +193,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -228,9 +224,9 @@ export const run = async function* (input, options) {
|
||||
const dtsPath = join(bundleDir, "types.d.ts");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -261,9 +257,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -284,16 +280,16 @@ export const run = async function* (input, options) {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v1");
|
||||
const h = await cas.put( "v1");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v1" };
|
||||
}
|
||||
`;
|
||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v2");
|
||||
const h = await cas.put( "v2");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v2" };
|
||||
}
|
||||
@@ -326,16 +322,16 @@ export const run = async function* (input, options) {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v1");
|
||||
const h = await cas.put( "v1");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v1" };
|
||||
}
|
||||
`;
|
||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v2");
|
||||
const h = await cas.put( "v2");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v2" };
|
||||
}
|
||||
@@ -378,9 +374,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -391,9 +387,9 @@ export const run = async function* (input, options) {
|
||||
expect(add1.ok).toBe(true);
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "y");
|
||||
const h = await cas.put( "y");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "y" };
|
||||
}
|
||||
@@ -446,9 +442,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -463,9 +459,9 @@ export const run = async function* (input, options) {
|
||||
const hash1 = add1.value.hash;
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "y");
|
||||
const h = await cas.put( "y");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "y" };
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@ import { addCliArgs } from "./bundle-fixture.js";
|
||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||
|
||||
/** Three-role workflow that respects `input.steps` for fork/resume. */
|
||||
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
|
||||
export const descriptor = {
|
||||
const threeRoleBundleSource = `export const descriptor = {
|
||||
description: "fork-cli",
|
||||
roles: {
|
||||
planner: { description: "planner", schema: {} },
|
||||
@@ -30,16 +28,16 @@ export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const has = (r) => input.steps.some((s) => s.role === r);
|
||||
if (!has("planner")) {
|
||||
const h = await putContentMerkleNode(cas, "p1");
|
||||
const h = await cas.put( "p1");
|
||||
yield { role: "planner", contentHash: h, meta: { k: "planner" }, refs: [h] };
|
||||
}
|
||||
if (!has("coder")) {
|
||||
const h = await putContentMerkleNode(cas, "c1");
|
||||
const h = await cas.put( "c1");
|
||||
yield { role: "coder", contentHash: h, meta: { k: "coder" }, refs: [h] };
|
||||
}
|
||||
if (!has("reviewer")) {
|
||||
const body = "rev-" + String(input.steps.length);
|
||||
const h = await putContentMerkleNode(cas, body);
|
||||
const h = await cas.put( body);
|
||||
yield { role: "reviewer", contentHash: h, meta: { k: "reviewer" }, refs: [h] };
|
||||
}
|
||||
return { returnCode: 0, summary: "done" };
|
||||
|
||||
@@ -23,9 +23,6 @@ import { resolveThreadRecord } from "../src/thread-scan.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
`;
|
||||
|
||||
const threadFixtureDescriptor = `export const descriptor = {
|
||||
description: "thread-cli",
|
||||
roles: {
|
||||
@@ -41,25 +38,23 @@ const threadFixtureDescriptor = `export const descriptor = {
|
||||
`;
|
||||
|
||||
const fastBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
let h = await cas.put( "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
h = await cas.put( "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const slowPlannerBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
let h = await cas.put( "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
h = await cas.put( "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
@@ -68,37 +63,34 @@ export const run = async function* (input, options) {
|
||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
|
||||
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
let h = await cas.put( "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
await new Promise((r) => setTimeout(r, 10000));
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
h = await cas.put( "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const pauseResumeBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "f");
|
||||
let h = await cas.put( "f");
|
||||
yield { role: "first", contentHash: h, meta: {}, refs: [h] };
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
h = await putContentMerkleNode(cas, "s");
|
||||
h = await cas.put( "s");
|
||||
yield { role: "second", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (_input, options) {
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
@@ -180,6 +172,9 @@ describe("cli thread commands", () => {
|
||||
}
|
||||
expect(threads.value.some((l) => l.includes(threadId))).toBe(true);
|
||||
|
||||
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
|
||||
await waitUntilRunningFileAbsent(runningPath, 120);
|
||||
|
||||
const shown = await cmdThreadShow(storageRoot, threadId);
|
||||
expect(shown.ok).toBe(true);
|
||||
if (!shown.ok) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/cli-workflow",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
|
||||
@@ -3,8 +3,8 @@ import { printCliError, printCliLine } from "./cli-output.js";
|
||||
import { getCommandRegistry } from "./cli-registry.js";
|
||||
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
||||
import { createCasDispatcher } from "./commands/cas/index.js";
|
||||
import { createInitDispatcher } from "./commands/init/index.js";
|
||||
import { dispatchConnect } from "./commands/connect/index.js";
|
||||
import { createInitDispatcher } from "./commands/init/index.js";
|
||||
import { dispatchSetup } from "./commands/setup/index.js";
|
||||
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
||||
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
||||
|
||||
@@ -23,7 +23,7 @@ function requireNextArg(argv: string[], i: number, flag: string): Result<string,
|
||||
function parseConnectArgv(argv: string[]): Result<ConnectOptions, string> {
|
||||
let name = osHostname().split(".")[0].toLowerCase();
|
||||
let gatewayUrl = DEFAULT_GATEWAY_URL;
|
||||
const gatewaySecret = process.env.WORKFLOW_GATEWAY_SECRET ?? "";
|
||||
const gatewaySecret = process.env.WORKFLOW_DASHBOARD_SECRET ?? "";
|
||||
const stringFlags: Record<string, (v: string) => void> = {
|
||||
"--name": (v) => {
|
||||
name = v;
|
||||
@@ -56,7 +56,7 @@ export async function dispatchConnect(storageRoot: string, argv: string[]): Prom
|
||||
const options = parsed.value;
|
||||
|
||||
if (options.gatewaySecret === "") {
|
||||
printCliLine("error: WORKFLOW_GATEWAY_SECRET is required");
|
||||
printCliLine("error: WORKFLOW_DASHBOARD_SECRET is required");
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,11 +48,13 @@ async function handleGatewayMessage(
|
||||
const headers = new Headers(req.headers);
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await params.appFetch(new Request(localUrl, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: req.body === null ? undefined : req.body,
|
||||
}));
|
||||
resp = await params.appFetch(
|
||||
new Request(localUrl, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: req.body === null ? undefined : req.body,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
params.log("R4N7BQ3C", `app.fetch failed: ${String(e)}`);
|
||||
const errBody: WsResponse = {
|
||||
|
||||
@@ -51,7 +51,6 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
||||
description: "Says hello — replace with your first role.",
|
||||
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
|
||||
schema: greeterMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -196,18 +196,13 @@ uncaged-workflow init workspace ${workspaceName}
|
||||
|
||||
function bundleTs(): string {
|
||||
return [
|
||||
'import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";',
|
||||
'import { mkdir, readdir, writeFile } from "node:fs/promises";',
|
||||
'import { join } from "node:path";',
|
||||
"",
|
||||
'const rootDir = join(import.meta.dir, "..");',
|
||||
'const workflowsDir = join(rootDir, "workflows");',
|
||||
'const distDir = join(rootDir, "dist");',
|
||||
"",
|
||||
"type JsonDeps = {",
|
||||
" dependencies: Record<string, string> | null;",
|
||||
" devDependencies: Record<string, string> | null;",
|
||||
"};",
|
||||
"",
|
||||
"function isEntryFile(name: string): boolean {",
|
||||
' return name.endsWith("-entry.ts");',
|
||||
"}",
|
||||
@@ -216,36 +211,6 @@ function bundleTs(): string {
|
||||
' return name.slice(0, -".ts".length);',
|
||||
"}",
|
||||
"",
|
||||
"async function uncagedWorkflowExternals(): Promise<string[]> {",
|
||||
" const names = new Set<string>();",
|
||||
' const paths = [join(rootDir, "package.json"), join(workflowsDir, "package.json")];',
|
||||
" for (const pkgPath of paths) {",
|
||||
" let raw: string;",
|
||||
" try {",
|
||||
' raw = await readFile(pkgPath, "utf8");',
|
||||
" } catch {",
|
||||
" continue;",
|
||||
" }",
|
||||
" const parsed = JSON.parse(raw) as JsonDeps;",
|
||||
" const blocks = [parsed.dependencies, parsed.devDependencies];",
|
||||
" for (const block of blocks) {",
|
||||
" if (block == null) {",
|
||||
" continue;",
|
||||
" }",
|
||||
" for (const key of Object.keys(block)) {",
|
||||
' if (key.startsWith("@uncaged/workflow")) {',
|
||||
" names.add(key);",
|
||||
" }",
|
||||
" }",
|
||||
" }",
|
||||
" }",
|
||||
" if (names.size === 0) {",
|
||||
' names.add("@uncaged/workflow-runtime");',
|
||||
' names.add("@uncaged/workflow-protocol");',
|
||||
" }",
|
||||
" return [...names];",
|
||||
"}",
|
||||
"",
|
||||
"async function main(): Promise<void> {",
|
||||
" await mkdir(distDir, { recursive: true });",
|
||||
" let files: string[];",
|
||||
@@ -261,7 +226,6 @@ function bundleTs(): string {
|
||||
' console.warn("bundle: no *-entry.ts files under workflows/");',
|
||||
" return;",
|
||||
" }",
|
||||
" const external = await uncagedWorkflowExternals();",
|
||||
" for (const file of entries) {",
|
||||
" const stem = entryStem(file);",
|
||||
" const entryPath = join(workflowsDir, file);",
|
||||
@@ -272,7 +236,6 @@ function bundleTs(): string {
|
||||
' target: "node",',
|
||||
" splitting: false,",
|
||||
' naming: { entry: "[name].esm.js" },',
|
||||
" external,",
|
||||
" });",
|
||||
" if (!result.success) {",
|
||||
" for (const log of result.logs) {",
|
||||
|
||||
@@ -18,13 +18,13 @@ export async function cmdThreadRemove(
|
||||
return err(`thread not found: ${threadId}`);
|
||||
}
|
||||
|
||||
if (resolved.source === "active") {
|
||||
await removeThreadEntry(resolved.bundleDir, threadId);
|
||||
} else {
|
||||
const hist = await removeThreadHistoryEntries(resolved.bundleDir, threadId);
|
||||
if (!hist.ok) {
|
||||
return hist;
|
||||
}
|
||||
// Always clear both stores: between resolve and delete the worker may finish and
|
||||
// move the thread from threads.json into history; branching only on resolved.source
|
||||
// would skip history removal and leave a dangling row.
|
||||
await removeThreadEntry(resolved.bundleDir, threadId);
|
||||
const hist = await removeThreadHistoryEntries(resolved.bundleDir, threadId);
|
||||
if (!hist.ok) {
|
||||
return hist;
|
||||
}
|
||||
|
||||
const infoPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.info.jsonl`);
|
||||
|
||||
@@ -110,7 +110,7 @@ export async function cmdAdd(
|
||||
return validated;
|
||||
}
|
||||
|
||||
const extracted = await extractBundleExports(resolvedPath, { storageRoot });
|
||||
const extracted = await extractBundleExports(resolvedPath);
|
||||
if (!extracted.ok) {
|
||||
return extracted;
|
||||
}
|
||||
|
||||
@@ -249,8 +249,7 @@ Each role has:
|
||||
|-------|------|---------|
|
||||
| \`description\` | string | What the role does |
|
||||
| \`systemPrompt\` | string | System prompt for the agent |
|
||||
| \`schema\` | ZodSchema | Validates the extracted meta |
|
||||
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
|
||||
| \`schema\` | ZodSchema | Validates meta; annotate CAS hash strings with \`.meta({ casRef: true })\` for DAG linking |
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -301,13 +300,36 @@ function createLazyAdapter(): AdapterFn {
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Agent CLI paths: use env() with absolute path defaults
|
||||
|
||||
Every env var in a bundle must have a sensible default — bundles must run without any env vars set. Use \`env(name, fallback)\` from \`@uncaged/workflow-util\`.
|
||||
|
||||
Discover the correct CLI path yourself (e.g. \`which cursor-agent\`, \`which hermes\`) and hardcode it as the fallback:
|
||||
|
||||
\`\`\`typescript
|
||||
import { env } from "@uncaged/workflow-util";
|
||||
|
||||
// ❌ WRONG — requireEnv and optionalEnv no longer exist
|
||||
const adapter = createCursorAgent({
|
||||
command: requireEnv("WORKFLOW_CURSOR_COMMAND", "set it"),
|
||||
...
|
||||
});
|
||||
|
||||
// ✅ CORRECT — env var is an override, fallback is the discovered absolute path
|
||||
const adapter = createCursorAgent({
|
||||
command: env("WORKFLOW_CURSOR_COMMAND", "/home/you/.local/bin/cursor-agent"),
|
||||
model: env("WORKFLOW_CURSOR_MODEL", "auto"),
|
||||
timeout: Number(env("WORKFLOW_CURSOR_TIMEOUT", "300000")),
|
||||
...
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
### Bundle import restrictions
|
||||
|
||||
The bundle validator only allows these import specifiers:
|
||||
- Node built-ins (\`node:fs\`, \`node:path\`, etc.)
|
||||
- \`@uncaged/workflow-*\` packages
|
||||
|
||||
Third-party packages (**including zod**) must be bundled into the \`.esm.js\` file, not left as external imports. When using \`bun build\`, only mark \`@uncaged/*\` as external.
|
||||
All other dependencies — including \`@uncaged/workflow-*\` packages, zod, and any third-party code — must be bundled into the \`.esm.js\` file. Bundles are fully self-contained: same Node/Bun version = same behavior.
|
||||
|
||||
### No default exports
|
||||
|
||||
|
||||
@@ -1,5 +1,62 @@
|
||||
# @uncaged/workflow-agent-cursor
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [f74b482]
|
||||
- Updated dependencies [f74b482]
|
||||
- @uncaged/workflow-util@0.5.0-alpha.4
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.4
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.3
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||
- @uncaged/workflow-util@0.5.0-alpha.3
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.2
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||
- @uncaged/workflow-util@0.5.0-alpha.2
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.1
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.1
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||
- @uncaged/workflow-util@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.0
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||
- @uncaged/workflow-util@0.5.0-alpha.0
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,36 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||
|
||||
describe("validateCursorAgentConfig", () => {
|
||||
test("accepts valid config with explicit workspace", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
const baseConfig = {
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null as string | null,
|
||||
timeout: 0,
|
||||
workspace: null as string | null,
|
||||
};
|
||||
|
||||
test("accepts valid config with null workspace and llmProvider", () => {
|
||||
describe("validateCursorAgentConfig", () => {
|
||||
test("accepts valid config", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
|
||||
...baseConfig,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects non-absolute command", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
...baseConfig,
|
||||
command: "cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
@@ -38,87 +27,38 @@ describe("validateCursorAgentConfig", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects empty workspace string", () => {
|
||||
test("rejects negative timeout", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: "",
|
||||
llmProvider: null,
|
||||
...baseConfig,
|
||||
timeout: -1,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects non-absolute workspace when set", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
...baseConfig,
|
||||
workspace: "relative/path",
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("workspace");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects null workspace without llmProvider", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("llmProvider");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects negative timeout", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: -1,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCursorAgent", () => {
|
||||
test("returns an AdapterFn with explicit workspace", () => {
|
||||
test("returns an AdapterFn", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("returns an AdapterFn with null workspace and llmProvider", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
llmProvider: { baseUrl: "http://localhost", apiKey: "test", model: "test" },
|
||||
...baseConfig,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("defers validation to call time (invalid config does not throw at construction)", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
...baseConfig,
|
||||
timeout: -1,
|
||||
workspace: "/tmp/test-project",
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("defers validation — null workspace without llmProvider does not throw at construction", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
workspace: null,
|
||||
llmProvider: null,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-cursor",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
@@ -12,8 +12,8 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-reactor": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"@uncaged/workflow-util-agent": "workspace:^",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AgentContext, LlmProvider } from "@uncaged/workflow-protocol";
|
||||
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
|
||||
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import type { ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
import type { LogFn } from "@uncaged/workflow-util";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
@@ -7,10 +7,7 @@ const workspaceSchema = z.object({
|
||||
workspace: z.string().describe("Absolute filesystem path of the project workspace"),
|
||||
});
|
||||
|
||||
const EXTRACT_SYSTEM_FN = (_toolName: string) =>
|
||||
`You are a workspace-path extractor. Given a workflow agent context (task description and previous step outputs), identify the absolute filesystem path of the project workspace where code changes should be made. Call the tool with the absolute path.`;
|
||||
|
||||
function buildExtractionInput(ctx: AgentContext): string {
|
||||
function buildExtractionInput(ctx: ThreadContext): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("## Task");
|
||||
lines.push(ctx.start.content);
|
||||
@@ -21,48 +18,25 @@ function buildExtractionInput(ctx: AgentContext): string {
|
||||
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Extract the absolute filesystem path of the project workspace where code changes should be made.",
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function extractWorkspacePath(
|
||||
ctx: AgentContext,
|
||||
provider: LlmProvider,
|
||||
ctx: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
logger: LogFn,
|
||||
): Promise<string | null> {
|
||||
const reactor = createThreadReactor<null>({
|
||||
llm: createLlmFn(provider),
|
||||
maxRounds: 2,
|
||||
staticTools: [],
|
||||
structuredToolFromSchema: (schema) => {
|
||||
const jsonSchema = z.toJSONSchema(schema);
|
||||
return {
|
||||
name: "set_workspace",
|
||||
tool: {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "set_workspace",
|
||||
description: "Set the extracted workspace path",
|
||||
parameters: jsonSchema as Record<string, unknown>,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
systemPromptForStructuredTool: EXTRACT_SYSTEM_FN,
|
||||
toolHandler: async () => "unknown tool",
|
||||
});
|
||||
const input = buildExtractionInput(ctx);
|
||||
const contentHash = await putContentNodeWithRefs(runtime.cas, input, []);
|
||||
|
||||
const result = await reactor({
|
||||
thread: null,
|
||||
input: buildExtractionInput(ctx),
|
||||
schema: workspaceSchema,
|
||||
});
|
||||
const result = await runtime.extract(workspaceSchema, contentHash);
|
||||
const workspace = result.meta.workspace.trim();
|
||||
|
||||
if (!result.ok) {
|
||||
logger("W8KN3QYT", `workspace extraction failed: ${result.error}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspace = result.value.workspace.trim();
|
||||
if (!workspace.startsWith("/")) {
|
||||
logger("H4PM7RXV", `workspace extraction returned non-absolute path: ${workspace}`);
|
||||
return null;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { AdapterFn } from "@uncaged/workflow-runtime";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
import type { AdapterFn, AgentFn, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
import { createLogger, type LogFn } from "@uncaged/workflow-util";
|
||||
import {
|
||||
buildThreadInput,
|
||||
createTextAdapter,
|
||||
createAgentAdapter,
|
||||
type SpawnCliError,
|
||||
spawnCli,
|
||||
} from "@uncaged/workflow-util-agent";
|
||||
@@ -33,36 +33,15 @@ function resolveCursorModel(model: string | null): string {
|
||||
return model === null ? "auto" : model;
|
||||
}
|
||||
|
||||
/** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */
|
||||
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
|
||||
const modelFlag = resolveCursorModel(config.model);
|
||||
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
||||
const logger = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
return createTextAdapter(async (ctx, prompt) => {
|
||||
const validated = validateCursorAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
let workspace: string;
|
||||
|
||||
if (config.workspace !== null) {
|
||||
workspace = config.workspace;
|
||||
} else {
|
||||
if (config.llmProvider === null) {
|
||||
throw new Error("cursor-agent: llmProvider is required when workspace is null");
|
||||
}
|
||||
const agentCtx = { ...ctx, currentRole: { name: "cursor", systemPrompt: prompt } };
|
||||
const extracted = await extractWorkspacePath(agentCtx, config.llmProvider, logger);
|
||||
if (extracted === null) {
|
||||
throw new Error(
|
||||
"cursor-agent: failed to extract workspace path from context. Provide an explicit workspace or ensure previous steps include a repoPath.",
|
||||
);
|
||||
}
|
||||
workspace = extracted;
|
||||
}
|
||||
type CursorAgentOpt = { prompt: string; workspace: string };
|
||||
|
||||
function createCursorAgentFn(
|
||||
config: CursorAgentConfig,
|
||||
modelFlag: string,
|
||||
timeoutMs: number | null,
|
||||
logger: LogFn,
|
||||
): AgentFn<CursorAgentOpt> {
|
||||
return async (ctx, { prompt, workspace }) => {
|
||||
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
|
||||
const threadInput = await buildThreadInput(ctx);
|
||||
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
||||
@@ -86,5 +65,33 @@ export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
|
||||
throwCursorSpawnError(run.error);
|
||||
}
|
||||
return run.value;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs `cursor-agent` with workspace from config or extracted from thread context via runtime.extract. */
|
||||
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
|
||||
const modelFlag = resolveCursorModel(config.model);
|
||||
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
||||
const logger = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
return createAgentAdapter(
|
||||
createCursorAgentFn(config, modelFlag, timeoutMs, logger),
|
||||
async (ctx, prompt, runtime: WorkflowRuntime) => {
|
||||
const validated = validateCursorAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
const workspace =
|
||||
config.workspace !== null
|
||||
? config.workspace
|
||||
: await extractWorkspacePath(ctx, runtime, logger);
|
||||
if (workspace === null) {
|
||||
throw new Error(
|
||||
"cursor-agent: failed to extract workspace path from context. Ensure the task prompt or previous steps include a project path.",
|
||||
);
|
||||
}
|
||||
return { prompt, workspace };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { LlmProvider } from "@uncaged/workflow-protocol";
|
||||
|
||||
export type CursorAgentConfig = {
|
||||
/** Absolute path to the cursor-agent CLI binary. */
|
||||
command: string;
|
||||
model: string | null;
|
||||
timeout: number;
|
||||
/** Explicit workspace path. When `null`, the agent extracts workspace from AgentContext via a ReAct LLM call. */
|
||||
/**
|
||||
* When non-null, use this workspace directory for `cursor-agent` instead of resolving it
|
||||
* from the thread via runtime extraction.
|
||||
*/
|
||||
workspace: string | null;
|
||||
/** Required when `workspace` is `null` — LLM provider used for workspace extraction. */
|
||||
llmProvider: LlmProvider | null;
|
||||
};
|
||||
|
||||
@@ -8,14 +8,11 @@ export function validateCursorAgentConfig(config: CursorAgentConfig): Result<voi
|
||||
if (!isAbsolute(config.command)) {
|
||||
return err("command must be an absolute path to the cursor-agent CLI binary");
|
||||
}
|
||||
if (config.workspace !== null && config.workspace.length === 0) {
|
||||
return err("workspace must be a non-empty string (absolute path) or null for auto-detection");
|
||||
}
|
||||
if (config.workspace === null && config.llmProvider === null) {
|
||||
return err("llmProvider is required when workspace is null (needed for workspace extraction)");
|
||||
}
|
||||
if (config.timeout < 0) {
|
||||
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
|
||||
}
|
||||
if (config.workspace !== null && !isAbsolute(config.workspace)) {
|
||||
return err("workspace must be an absolute filesystem path when set");
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
@@ -6,5 +6,9 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
|
||||
"references": [
|
||||
{ "path": "../workflow-cas" },
|
||||
{ "path": "../workflow-runtime" },
|
||||
{ "path": "../workflow-util-agent" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# @uncaged/workflow-agent-hermes
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.1
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-hermes",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AdapterFn } from "@uncaged/workflow-runtime";
|
||||
import type { AdapterFn, AgentFn } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
buildThreadInput,
|
||||
createTextAdapter,
|
||||
createAgentAdapter,
|
||||
type SpawnCliError,
|
||||
spawnCli,
|
||||
} from "@uncaged/workflow-util-agent";
|
||||
@@ -11,6 +11,8 @@ import { validateHermesAgentConfig } from "./validate-config.js";
|
||||
|
||||
const HERMES_DEFAULT_MAX_TURNS = 90;
|
||||
|
||||
type HermesAgentOpt = { prompt: string };
|
||||
|
||||
export type { HermesAgentConfig } from "./types.js";
|
||||
export { validateHermesAgentConfig } from "./validate-config.js";
|
||||
|
||||
@@ -29,16 +31,10 @@ function throwHermesSpawnError(error: SpawnCliError): never {
|
||||
throw new Error("hermes: unknown spawn error");
|
||||
}
|
||||
|
||||
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
|
||||
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
||||
function createHermesAgentFn(config: HermesAgentConfig): AgentFn<HermesAgentOpt> {
|
||||
const timeoutMs = config.timeout;
|
||||
|
||||
return createTextAdapter(async (ctx, prompt) => {
|
||||
const validated = validateHermesAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
return async (ctx, { prompt }) => {
|
||||
const threadInput = await buildThreadInput(ctx);
|
||||
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
||||
const args = [
|
||||
@@ -61,5 +57,16 @@ export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
||||
throwHermesSpawnError(run.error);
|
||||
}
|
||||
return run.value;
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
|
||||
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
||||
return createAgentAdapter(createHermesAgentFn(config), async (_ctx, prompt, _runtime) => {
|
||||
const validated = validateHermesAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
return { prompt };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# @uncaged/workflow-agent-llm
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.1
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-llm",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { type AdapterFn, err, type LlmProvider, ok, type Result } from "@uncaged/workflow-runtime";
|
||||
import { createTextAdapter } from "@uncaged/workflow-util-agent";
|
||||
import {
|
||||
type AdapterFn,
|
||||
type AgentFn,
|
||||
err,
|
||||
type LlmProvider,
|
||||
ok,
|
||||
type Result,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import { createAgentAdapter } from "@uncaged/workflow-util-agent";
|
||||
|
||||
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
|
||||
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
||||
@@ -91,9 +98,10 @@ export async function chatCompletionText(options: {
|
||||
return parseAssistantText(res.value);
|
||||
}
|
||||
|
||||
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
|
||||
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
|
||||
return createTextAdapter(async (ctx, prompt) => {
|
||||
type LlmAgentOpt = { prompt: string };
|
||||
|
||||
function createLlmAgent(provider: LlmProvider): AgentFn<LlmAgentOpt> {
|
||||
return async (ctx, { prompt }) => {
|
||||
const result = await chatCompletionText({
|
||||
provider,
|
||||
messages: [
|
||||
@@ -105,5 +113,12 @@ export function createLlmAdapter(provider: LlmProvider): AdapterFn {
|
||||
throw new Error(`llm: ${formatLlmChatError(result.error)}`);
|
||||
}
|
||||
return result.value;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
|
||||
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
|
||||
return createAgentAdapter(createLlmAgent(provider), async (_ctx, prompt, _runtime) => ({
|
||||
prompt,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
# @uncaged/workflow-agent-react
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f74b482]
|
||||
- Updated dependencies [f74b482]
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||
- @uncaged/workflow-reactor@0.5.0-alpha.4
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||
- @uncaged/workflow-reactor@0.5.0-alpha.3
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||
- @uncaged/workflow-reactor@0.5.0-alpha.2
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.1
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||
- @uncaged/workflow-reactor@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||
- @uncaged/workflow-reactor@0.5.0-alpha.0
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-react",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# @uncaged/workflow-cas
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [f74b482]
|
||||
- Updated dependencies [f74b482]
|
||||
- @uncaged/workflow-util@0.5.0-alpha.4
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||
- @uncaged/workflow-util@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||
- @uncaged/workflow-util@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||
- @uncaged/workflow-util@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||
- @uncaged/workflow-util@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-cas",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
|
||||
@@ -1,29 +1,16 @@
|
||||
export { createCasStore } from "./cas.js";
|
||||
export { collectRefs } from "./collect-refs.js";
|
||||
export { hashString, hashWorkflowBundleBytes } from "./hash.js";
|
||||
export { hashWorkflowBundleBytes } from "./hash.js";
|
||||
export {
|
||||
createContentMerkleNode,
|
||||
getContentMerklePayload,
|
||||
parseMerkleNode,
|
||||
putContentMerkleNode,
|
||||
putStepMerkleNode,
|
||||
putThreadMerkleNode,
|
||||
serializeMerkleNode,
|
||||
} from "./merkle.js";
|
||||
export type { ParsedCasThreadNode } from "./nodes.js";
|
||||
export {
|
||||
isCasNodeYaml,
|
||||
parseCasThreadNode,
|
||||
putContentNodeWithRefs,
|
||||
putStartNode,
|
||||
putStateNode,
|
||||
serializeCasNode,
|
||||
} from "./nodes.js";
|
||||
export { findReachableHashes } from "./reachable.js";
|
||||
export type {
|
||||
CasStore,
|
||||
MerkleNode,
|
||||
MerkleNodeType,
|
||||
StepMerklePayload,
|
||||
ThreadMerklePayload,
|
||||
} from "./types.js";
|
||||
export type { CasStore } from "./types.js";
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util" }]
|
||||
|
||||
@@ -122,6 +122,7 @@ export type WorkflowGraph = {
|
||||
|
||||
export type WorkflowRoleDescriptor = {
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
schema: Record<string, unknown>;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ import { Sidebar } from "./components/sidebar.tsx";
|
||||
import { StatusBar } from "./components/status-bar.tsx";
|
||||
import { ThreadDetail } from "./components/thread-detail.tsx";
|
||||
import { ThreadList } from "./components/thread-list.tsx";
|
||||
import { WorkflowDetail } from "./components/workflow-detail.tsx";
|
||||
import { WorkflowList } from "./components/workflow-list.tsx";
|
||||
import { useHashRoute } from "./use-hash-route.ts";
|
||||
|
||||
export function App() {
|
||||
const [authed, setAuthed] = useState(hasApiKey());
|
||||
const { view, client, threadId, setView, setClient, setThreadId } = useHashRoute();
|
||||
const { view, client, threadId, workflowName, setView, setClient, setThreadId, setWorkflowName } =
|
||||
useHashRoute();
|
||||
const [showRun, setShowRun] = useState(false);
|
||||
|
||||
if (!authed) {
|
||||
@@ -46,7 +48,16 @@ export function App() {
|
||||
{client && view === "threads" && threadId !== null && (
|
||||
<ThreadDetail client={client} threadId={threadId} onBack={() => setThreadId(null)} />
|
||||
)}
|
||||
{client && view === "workflows" && <WorkflowList client={client} />}
|
||||
{client && view === "workflows" && workflowName === null && (
|
||||
<WorkflowList client={client} onSelect={setWorkflowName} />
|
||||
)}
|
||||
{client && view === "workflows" && workflowName !== null && (
|
||||
<WorkflowDetail
|
||||
client={client}
|
||||
workflowName={workflowName}
|
||||
onBack={() => setWorkflowName(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
{showRun && client && (
|
||||
|
||||
@@ -39,7 +39,8 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
|
||||
states.set(role, !hasResult && isLast ? "active" : "completed");
|
||||
}
|
||||
|
||||
if (roleRecords.length > 0) {
|
||||
const hasStart = records.some((r) => r.type === "thread-start");
|
||||
if (hasStart) {
|
||||
states.set("__start__", "completed");
|
||||
}
|
||||
if (hasResult) {
|
||||
@@ -52,6 +53,35 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
|
||||
return states;
|
||||
}
|
||||
|
||||
function isClickableGraphNode(nodeStates: Map<string, NodeState>, nodeId: string): boolean {
|
||||
const state = nodeStates.get(nodeId);
|
||||
return state !== undefined && state !== "default";
|
||||
}
|
||||
|
||||
function scrollToFirstRecord(): void {
|
||||
const firstCard = document.querySelector('[data-record-index="0"]');
|
||||
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
function scrollToRoleOccurrence(
|
||||
nodeId: string,
|
||||
indicesByRole: Map<string, number[]>,
|
||||
clickCycleRef: { current: Map<string, number> },
|
||||
onHighlight: (role: string) => void,
|
||||
): void {
|
||||
const indices = indicesByRole.get(nodeId);
|
||||
if (indices === undefined || indices.length === 0) return;
|
||||
|
||||
const cycle = clickCycleRef.current.get(nodeId) ?? 0;
|
||||
const idx = indices[cycle % indices.length];
|
||||
clickCycleRef.current.set(nodeId, cycle + 1);
|
||||
|
||||
const el = document.querySelector(`[data-record-index="${idx}"]`);
|
||||
if (el === null) return;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
onHighlight(nodeId);
|
||||
}
|
||||
|
||||
export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||
const sse = useSSE(client, threadId);
|
||||
const { status, data, error } = useFetch(() => getThread(client, threadId), [client, threadId]);
|
||||
@@ -79,29 +109,47 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||
const descriptor = descriptorFetch.status === "ok" ? descriptorFetch.data : null;
|
||||
const nodeStates = useMemo(() => computeNodeStates(records), [records]);
|
||||
|
||||
const firstIndexByRole = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
const indicesByRole = useMemo(() => {
|
||||
const m = new Map<string, number[]>();
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const r = records[i];
|
||||
if (r.type === "role" && !m.has(r.role)) {
|
||||
m.set(r.role, i);
|
||||
if (r.type === "role") {
|
||||
const list = m.get(r.role) ?? [];
|
||||
list.push(i);
|
||||
m.set(r.role, list);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [records]);
|
||||
|
||||
const handleGraphNodeClick = useCallback((roleName: string) => {
|
||||
const el = firstCardByRoleRef.current.get(roleName);
|
||||
if (el == null) return;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
// Track which occurrence to jump to next per role (cycling)
|
||||
const clickCycleRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
const highlightRole = useCallback((role: string) => {
|
||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||
setHighlightedRole(roleName);
|
||||
setHighlightedRole(role);
|
||||
highlightTimerRef.current = setTimeout(() => {
|
||||
setHighlightedRole(null);
|
||||
highlightTimerRef.current = null;
|
||||
}, 1500);
|
||||
}, []);
|
||||
|
||||
const handleGraphNodeClick = useCallback(
|
||||
(nodeId: string) => {
|
||||
if (!isClickableGraphNode(nodeStates, nodeId)) return;
|
||||
if (nodeId === "__start__") {
|
||||
scrollToFirstRecord();
|
||||
return;
|
||||
}
|
||||
if (nodeId === "__end__") {
|
||||
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
return;
|
||||
}
|
||||
scrollToRoleOccurrence(nodeId, indicesByRole, clickCycleRef, highlightRole);
|
||||
},
|
||||
[nodeStates, indicesByRole, highlightRole],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||
@@ -237,11 +285,13 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||
{records.map((r, i) => {
|
||||
const key = `${threadId}-${i}`;
|
||||
if (r.type === "role") {
|
||||
const isFirstForRole = firstIndexByRole.get(r.role) === i;
|
||||
const roleIndices = indicesByRole.get(r.role);
|
||||
const isFirstForRole = roleIndices !== undefined && roleIndices[0] === i;
|
||||
const flash = highlightedRole === r.role;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
data-record-index={i}
|
||||
ref={(el) => {
|
||||
if (!isFirstForRole) return;
|
||||
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
|
||||
@@ -252,7 +302,11 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <RecordCard key={key} record={r} highlighted={false} />;
|
||||
return (
|
||||
<div key={key} data-record-index={i}>
|
||||
<RecordCard record={r} highlighted={false} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={recordsEndRef} aria-hidden />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import type { WorkflowDetail as WorkflowDetailData, WorkflowRoleDescriptor } from "../api.ts";
|
||||
import { getWorkflowDetail } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
import { Markdown } from "./markdown.tsx";
|
||||
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
||||
|
||||
type Props = {
|
||||
client: string;
|
||||
workflowName: string;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
function versionCount(detail: WorkflowDetailData): number {
|
||||
return detail.history.length + 1;
|
||||
}
|
||||
|
||||
// ── Schema rendering helpers ────────────────────────────────────────
|
||||
|
||||
type SchemaRow = {
|
||||
key: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
depth: number;
|
||||
prefix: string;
|
||||
isVariantHeader: boolean;
|
||||
};
|
||||
|
||||
function resolveType(prop: Record<string, unknown>): string {
|
||||
if (prop.type === "array") {
|
||||
const items = prop.items as Record<string, unknown> | undefined;
|
||||
if (items !== undefined) {
|
||||
const itemType = String(items.type ?? "unknown");
|
||||
return `${itemType}[]`;
|
||||
}
|
||||
return "array";
|
||||
}
|
||||
return String(prop.type ?? "unknown");
|
||||
}
|
||||
|
||||
function variantLabel(
|
||||
variantProps: Record<string, Record<string, unknown>>,
|
||||
variantIndex: number,
|
||||
): string {
|
||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||
if (pDef.const !== undefined) return `${pName}: ${String(pDef.const)}`;
|
||||
}
|
||||
return `Variant ${variantIndex + 1}`;
|
||||
}
|
||||
|
||||
function childPrefixForDepth(depth: number, parentPrefix: string): string {
|
||||
return depth > 0 ? `${parentPrefix} ` : " ";
|
||||
}
|
||||
|
||||
function flattenOneOfVariants(
|
||||
oneOf: Array<Record<string, unknown>>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
): SchemaRow[] {
|
||||
const rows: SchemaRow[] = [];
|
||||
for (let vi = 0; vi < oneOf.length; vi++) {
|
||||
const variant = oneOf[vi];
|
||||
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const isLast = vi === oneOf.length - 1;
|
||||
const connector = isLast ? "└" : "├";
|
||||
rows.push({
|
||||
key: `${keyPrefix}variant-${vi}`,
|
||||
name: `${parentPrefix}${connector} ${variantLabel(variantProps, vi)}`,
|
||||
type: "",
|
||||
description: "",
|
||||
depth,
|
||||
prefix: parentPrefix,
|
||||
isVariantHeader: true,
|
||||
});
|
||||
const variantChildPrefix = `${parentPrefix}${isLast ? " " : "│ "}`;
|
||||
const variantRequired = new Set<string>(
|
||||
Array.isArray(variant.required) ? (variant.required as string[]) : [],
|
||||
);
|
||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||
if (pDef.const !== undefined) continue;
|
||||
rows.push(
|
||||
...flattenProperty(
|
||||
pName,
|
||||
pDef,
|
||||
depth + 1,
|
||||
variantChildPrefix,
|
||||
`${keyPrefix}v${vi}-`,
|
||||
variantRequired,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function flattenSchemaProperties(
|
||||
schema: Record<string, unknown>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
): SchemaRow[] {
|
||||
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const required = new Set<string>(
|
||||
Array.isArray(schema.required) ? (schema.required as string[]) : [],
|
||||
);
|
||||
const rows: SchemaRow[] = [];
|
||||
for (const [name, prop] of Object.entries(props)) {
|
||||
rows.push(...flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function flattenSchema(
|
||||
schema: Record<string, unknown>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
): SchemaRow[] {
|
||||
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(oneOf) && oneOf.length > 0) {
|
||||
return flattenOneOfVariants(oneOf, depth, parentPrefix, keyPrefix);
|
||||
}
|
||||
return flattenSchemaProperties(schema, depth, parentPrefix, keyPrefix);
|
||||
}
|
||||
|
||||
function flattenNestedPropertyRows(
|
||||
name: string,
|
||||
prop: Record<string, unknown>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
hasOneOf: boolean,
|
||||
): SchemaRow[] {
|
||||
const childPrefix = childPrefixForDepth(depth, parentPrefix);
|
||||
const nestedKeyPrefix = `${keyPrefix}${name}-`;
|
||||
|
||||
if (prop.type === "object" && prop.properties !== undefined) {
|
||||
return flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, nestedKeyPrefix);
|
||||
}
|
||||
|
||||
if (prop.type === "array") {
|
||||
const items = prop.items as Record<string, unknown> | undefined;
|
||||
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
|
||||
return flattenSchema(items, depth + 1, childPrefix, nestedKeyPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOneOf) {
|
||||
return flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, nestedKeyPrefix);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function flattenProperty(
|
||||
name: string,
|
||||
prop: Record<string, unknown>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
required: Set<string>,
|
||||
): SchemaRow[] {
|
||||
const hasOneOf = Array.isArray(prop.oneOf) && (prop.oneOf as unknown[]).length > 0;
|
||||
let type = hasOneOf ? "⊕ oneOf" : resolveType(prop);
|
||||
if (!required.has(name)) type += "?";
|
||||
|
||||
const rows: SchemaRow[] = [
|
||||
{
|
||||
key: `${keyPrefix}${name}`,
|
||||
name: depth > 0 ? `${parentPrefix}└─ ${name}` : name,
|
||||
type,
|
||||
description: String(prop.description ?? ""),
|
||||
depth,
|
||||
prefix: parentPrefix,
|
||||
isVariantHeader: false,
|
||||
},
|
||||
];
|
||||
|
||||
rows.push(...flattenNestedPropertyRows(name, prop, depth, parentPrefix, keyPrefix, hasOneOf));
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ── Components ──────────────────────────────────────────────────────
|
||||
|
||||
function RoleCard({ roleName, role }: { roleName: string; role: WorkflowRoleDescriptor }) {
|
||||
const rows = flattenSchema(role.schema, 0, "", `${roleName}-`);
|
||||
return (
|
||||
<div
|
||||
id={`role-${roleName}`}
|
||||
className="rounded-lg border p-4"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<h4 className="text-sm font-semibold font-mono mb-1" style={{ color: "var(--color-text)" }}>
|
||||
{roleName}
|
||||
</h4>
|
||||
{role.description !== "" && (
|
||||
<p className="text-xs mb-3" style={{ color: "var(--color-text-muted)" }}>
|
||||
{role.description}
|
||||
</p>
|
||||
)}
|
||||
{role.systemPrompt !== "" && (
|
||||
<details className="mb-3">
|
||||
<summary
|
||||
className="text-[10px] uppercase tracking-wider font-medium cursor-pointer select-none"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
System Prompt
|
||||
</summary>
|
||||
<div
|
||||
className="mt-1 p-2 rounded overflow-y-auto text-xs"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
maxHeight: "300px",
|
||||
}}
|
||||
>
|
||||
<Markdown content={role.systemPrompt} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<div>
|
||||
<p
|
||||
className="text-[10px] uppercase tracking-wider mb-1 font-medium"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Meta Schema
|
||||
</p>
|
||||
<table className="w-full text-xs" style={{ borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--color-border)" }}>
|
||||
<th
|
||||
className="text-left py-1 pr-3 font-medium"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Field
|
||||
</th>
|
||||
<th
|
||||
className="text-left py-1 pr-3 font-medium"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Type
|
||||
</th>
|
||||
<th
|
||||
className="text-left py-1 font-medium"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Description
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr
|
||||
key={r.key}
|
||||
style={{
|
||||
borderBottom: r.isVariantHeader ? "none" : "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<td
|
||||
className="py-1 pr-3 font-mono whitespace-pre"
|
||||
style={{
|
||||
color: r.isVariantHeader ? "var(--color-text-muted)" : "var(--color-accent)",
|
||||
fontStyle: r.isVariantHeader ? "italic" : "normal",
|
||||
}}
|
||||
>
|
||||
{r.name}
|
||||
</td>
|
||||
<td className="py-1 pr-3 font-mono" style={{ color: "var(--color-text-muted)" }}>
|
||||
{r.type}
|
||||
</td>
|
||||
<td className="py-1" style={{ color: "var(--color-text)" }}>
|
||||
{r.description || (r.isVariantHeader ? "" : "—")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{rows.length === 0 && Object.keys(role.schema).length > 0 && (
|
||||
<pre
|
||||
className="text-[10px] font-mono p-2 rounded overflow-x-auto"
|
||||
style={{ background: "var(--color-bg)", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{JSON.stringify(role.schema, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ──────────────────────────────────────────────────
|
||||
|
||||
export function WorkflowDetail({ client, workflowName, onBack }: Props) {
|
||||
const { status, data, error } = useFetch(
|
||||
() => getWorkflowDetail(client, workflowName),
|
||||
[client, workflowName],
|
||||
);
|
||||
|
||||
const [highlightedRole, setHighlightedRole] = useState<string | null>(null);
|
||||
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const detail = status === "ok" ? data : null;
|
||||
const descriptor = detail?.descriptor ?? null;
|
||||
const roleEntries = descriptor !== null ? Object.entries(descriptor.roles) : [];
|
||||
const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
|
||||
const hasGraph = descriptor !== null && edgeCount > 0;
|
||||
|
||||
const allLitStates = useMemo(() => {
|
||||
const m = new Map<string, NodeState>();
|
||||
m.set("__start__", "completed");
|
||||
m.set("__end__", "completed");
|
||||
for (const [name] of roleEntries) {
|
||||
m.set(name, "completed");
|
||||
}
|
||||
return m;
|
||||
}, [roleEntries]);
|
||||
|
||||
function handleGraphNodeClick(nodeId: string) {
|
||||
const el = document.getElementById(`role-${nodeId}`);
|
||||
if (el === null) return;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||
setHighlightedRole(nodeId);
|
||||
highlightTimerRef.current = setTimeout(() => {
|
||||
setHighlightedRole(null);
|
||||
highlightTimerRef.current = null;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-sm hover:underline"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
← Back to workflows
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-4 font-mono">{workflowName}</h2>
|
||||
|
||||
{status === "loading" && <p style={{ color: "var(--color-text-muted)" }}>Loading...</p>}
|
||||
{status === "error" && <p style={{ color: "var(--color-error)" }}>Error: {error}</p>}
|
||||
|
||||
{detail !== null && (
|
||||
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 160px)" }}>
|
||||
{/* Left: fixed graph sidebar */}
|
||||
{hasGraph && (
|
||||
<div
|
||||
className="shrink-0"
|
||||
style={{
|
||||
width: 280,
|
||||
position: "sticky",
|
||||
top: 16,
|
||||
height: "calc(100vh - 160px)",
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded-lg border h-full flex flex-col overflow-hidden"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 text-xs"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<span className="font-mono">Workflow graph</span>
|
||||
<span>
|
||||
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<WorkflowGraph
|
||||
graph={descriptor.graph}
|
||||
roles={descriptor.roles}
|
||||
nodeStates={allLitStates}
|
||||
onNodeClick={handleGraphNodeClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right: scrollable content */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
{/* Workflow overview */}
|
||||
<div
|
||||
className="rounded-lg border p-4"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<p
|
||||
className="text-sm whitespace-pre-wrap mb-3"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{descriptor !== null && descriptor.description !== ""
|
||||
? descriptor.description
|
||||
: "—"}
|
||||
</p>
|
||||
<div className="flex gap-4 text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
<span>
|
||||
Hash:{" "}
|
||||
<code className="font-mono" style={{ color: "var(--color-accent)" }}>
|
||||
{detail.hash}
|
||||
</code>
|
||||
</span>
|
||||
<span>
|
||||
{versionCount(detail)} version{versionCount(detail) !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{roleEntries.length > 0 && (
|
||||
<span>
|
||||
{roleEntries.length} role{roleEntries.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role cards */}
|
||||
{roleEntries.map(([name, role]) => (
|
||||
<div
|
||||
key={name}
|
||||
style={{
|
||||
transition: "box-shadow 0.3s",
|
||||
boxShadow: highlightedRole === name ? "0 0 0 2px var(--color-accent)" : "none",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<RoleCard roleName={name} role={role} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,22 @@ import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from "
|
||||
import type { ConditionEdgeData } from "./types.ts";
|
||||
|
||||
// Must match the FEEDBACK_OFFSET_X in use-layout.ts
|
||||
const FEEDBACK_OFFSET_X = 100;
|
||||
const FEEDBACK_OFFSET_X = 80;
|
||||
// Radius for feedback edge corners
|
||||
const FEEDBACK_RADIUS = 16;
|
||||
|
||||
/**
|
||||
* Build an SVG path for a feedback (back) edge that routes to the given side of the nodes.
|
||||
* The path goes: source → arc → vertical up → arc → target
|
||||
* Build an SVG path for an edge routed to the side of the nodes.
|
||||
* Works for both feedback (bottom→up) and skip-forward (top→down) edges.
|
||||
* The path goes: source → horizontal to side → vertical → horizontal to target
|
||||
*/
|
||||
function feedbackPath(sourceX: number, sourceY: number, targetX: number, targetY: number, side: "right" | "left"): string {
|
||||
function sidePath(
|
||||
sourceX: number,
|
||||
sourceY: number,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
side: "right" | "left",
|
||||
): string {
|
||||
const d = side === "right" ? 1 : -1;
|
||||
const offsetX =
|
||||
side === "right"
|
||||
@@ -18,11 +25,16 @@ function feedbackPath(sourceX: number, sourceY: number, targetX: number, targetY
|
||||
: Math.min(sourceX, targetX) - FEEDBACK_OFFSET_X;
|
||||
const r = FEEDBACK_RADIUS;
|
||||
|
||||
// Direction: going up (feedback) or down (skip-forward)
|
||||
const goingDown = targetY > sourceY;
|
||||
const vertSourceY = goingDown ? sourceY + r : sourceY - r;
|
||||
const vertTargetY = goingDown ? targetY - r : targetY + r;
|
||||
|
||||
const segments = [
|
||||
`M ${sourceX} ${sourceY}`,
|
||||
`L ${offsetX - d * r} ${sourceY}`,
|
||||
`Q ${offsetX} ${sourceY} ${offsetX} ${sourceY - r}`,
|
||||
`L ${offsetX} ${targetY + r}`,
|
||||
`Q ${offsetX} ${sourceY} ${offsetX} ${vertSourceY}`,
|
||||
`L ${offsetX} ${vertTargetY}`,
|
||||
`Q ${offsetX} ${targetY} ${offsetX - d * r} ${targetY}`,
|
||||
`L ${targetX} ${targetY}`,
|
||||
];
|
||||
@@ -56,7 +68,7 @@ export function ConditionEdge(props: EdgeProps) {
|
||||
|
||||
if (isFeedback) {
|
||||
const side = edgeData?.feedbackSide ?? "right";
|
||||
path = feedbackPath(sourceX, sourceY, targetX, targetY, side);
|
||||
path = sidePath(sourceX, sourceY, targetX, targetY, side);
|
||||
const offsetX =
|
||||
side === "right"
|
||||
? Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X
|
||||
@@ -88,12 +100,7 @@ export function ConditionEdge(props: EdgeProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={path}
|
||||
markerEnd={markerEnd}
|
||||
style={{ stroke, strokeWidth: 1.5 }}
|
||||
/>
|
||||
<BaseEdge id={id} path={path} markerEnd={markerEnd} style={{ stroke, strokeWidth: 1.5 }} />
|
||||
{label !== "" && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
|
||||
@@ -31,7 +31,7 @@ export function RoleNode(props: NodeProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`px-3 py-2 rounded-md border-2 text-xs font-medium ${t.state !== "default" ? "cursor-pointer" : ""} ${isActive ? "wf-node-pulse" : ""}`}
|
||||
className={`px-3 py-2 rounded-md border-2 text-xs font-medium ${data.state !== "default" ? "cursor-pointer" : ""} ${isActive ? "wf-node-pulse" : ""}`}
|
||||
style={{
|
||||
width: 180,
|
||||
height: 60,
|
||||
@@ -45,7 +45,41 @@ export function RoleNode(props: NodeProps) {
|
||||
}}
|
||||
title={data.description}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} style={handleStyle} isConnectable={false} />
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="top-in"
|
||||
style={handleStyle}
|
||||
isConnectable={false}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="left-in"
|
||||
style={handleStyle}
|
||||
isConnectable={false}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Right}
|
||||
id="right-in"
|
||||
style={handleStyle}
|
||||
isConnectable={false}
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Left}
|
||||
id="left-out"
|
||||
style={handleStyle}
|
||||
isConnectable={false}
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="right-out"
|
||||
style={handleStyle}
|
||||
isConnectable={false}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 font-mono">
|
||||
{icon !== null && (
|
||||
<span
|
||||
@@ -63,7 +97,13 @@ export function RoleNode(props: NodeProps) {
|
||||
{data.description}
|
||||
</div>
|
||||
)}
|
||||
<Handle type="source" position={Position.Bottom} style={handleStyle} isConnectable={false} />
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="bottom-out"
|
||||
style={handleStyle}
|
||||
isConnectable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function TerminalNode(props: NodeProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-full border-2 flex items-center justify-center text-[10px] font-bold ${isActive ? "wf-node-pulse" : ""}`}
|
||||
className={`rounded-full border-2 flex items-center justify-center text-[10px] font-bold ${isActive ? "wf-node-pulse" : ""} ${data.state !== "default" ? "cursor-pointer" : ""}`}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
@@ -45,11 +45,34 @@ export function TerminalNode(props: NodeProps) {
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="bottom-out"
|
||||
style={handleStyle}
|
||||
isConnectable={false}
|
||||
/>
|
||||
) : (
|
||||
<Handle type="target" position={Position.Top} style={handleStyle} isConnectable={false} />
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="top-in"
|
||||
style={handleStyle}
|
||||
isConnectable={false}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="left-in"
|
||||
style={handleStyle}
|
||||
isConnectable={false}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Right}
|
||||
id="right-in"
|
||||
style={handleStyle}
|
||||
isConnectable={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isStart ? "▶" : "■"}
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ const TERMINAL_NODE_SIZE = 40;
|
||||
// Vertical gap between nodes in the spine
|
||||
const LAYER_GAP = 80;
|
||||
// Horizontal offset for feedback (back) edges routed on the right side
|
||||
const FEEDBACK_OFFSET_X = 100;
|
||||
const FEEDBACK_OFFSET_X = 80;
|
||||
|
||||
type LayoutInput = {
|
||||
edges: readonly WorkflowGraphEdge[];
|
||||
@@ -36,76 +36,150 @@ function edgeKey(e: WorkflowGraphEdge): string {
|
||||
return `${e.from}->${e.to}::${e.condition}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the linear spine from the graph using topological ordering.
|
||||
* Forward edges go from lower rank to higher rank; feedback edges go backwards.
|
||||
* Self-loops are neither forward nor feedback — they're handled separately.
|
||||
*/
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: topological sort is inherently branchy
|
||||
function extractSpine(edges: readonly WorkflowGraphEdge[]): string[] {
|
||||
// Collect all node IDs
|
||||
function collectNodeIds(edges: readonly WorkflowGraphEdge[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const e of edges) {
|
||||
ids.add(e.from);
|
||||
ids.add(e.to);
|
||||
}
|
||||
|
||||
// Build adjacency for forward edges only (non-self-loop, non-FALLBACK-back)
|
||||
// Strategy: BFS from __start__, picking the first non-FALLBACK forward edge,
|
||||
// or FALLBACK if no other option.
|
||||
const forwardAdj = new Map<string, string[]>();
|
||||
for (const e of edges) {
|
||||
if (e.from === e.to) continue;
|
||||
const existing = forwardAdj.get(e.from) ?? [];
|
||||
existing.push(e.to);
|
||||
forwardAdj.set(e.from, existing);
|
||||
}
|
||||
|
||||
// Walk the main path: prefer non-FALLBACK edges for the spine ordering
|
||||
const visited = new Set<string>();
|
||||
const spine: string[] = [];
|
||||
|
||||
// Build a set of "primary" next targets per node (non-FALLBACK first)
|
||||
const primaryNext = new Map<string, string>();
|
||||
const edgesByFrom = new Map<string, WorkflowGraphEdge[]>();
|
||||
for (const e of edges) {
|
||||
if (e.from === e.to) continue;
|
||||
const list = edgesByFrom.get(e.from) ?? [];
|
||||
list.push(e);
|
||||
edgesByFrom.set(e.from, list);
|
||||
}
|
||||
|
||||
// For each node, the "primary" next is the first non-FALLBACK target,
|
||||
// or the FALLBACK target if all edges are FALLBACK
|
||||
for (const [from, edgeList] of edgesByFrom) {
|
||||
const nonFallback = edgeList.find((e) => e.condition !== "FALLBACK");
|
||||
const fallback = edgeList.find((e) => e.condition === "FALLBACK");
|
||||
primaryNext.set(from, nonFallback?.to ?? fallback?.to ?? "");
|
||||
}
|
||||
|
||||
// Walk the spine from __start__
|
||||
let current: string | null = START_ID;
|
||||
while (current !== null && !visited.has(current)) {
|
||||
visited.add(current);
|
||||
spine.push(current);
|
||||
const next = primaryNext.get(current);
|
||||
if (next !== undefined && next !== "" && !visited.has(next)) {
|
||||
current = next;
|
||||
} else {
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining nodes not on the main path (shouldn't normally happen)
|
||||
for (const id of ids) {
|
||||
if (!visited.has(id)) {
|
||||
spine.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
return spine;
|
||||
return ids;
|
||||
}
|
||||
|
||||
function detectBackEdges(ids: Set<string>, edges: readonly WorkflowGraphEdge[]): Set<string> {
|
||||
const WHITE = 0;
|
||||
const GRAY = 1;
|
||||
const BLACK = 2;
|
||||
const backEdges = new Set<string>();
|
||||
const color = new Map<string, number>();
|
||||
for (const id of ids) color.set(id, WHITE);
|
||||
|
||||
const fullAdj = new Map<string, string[]>();
|
||||
for (const id of ids) fullAdj.set(id, []);
|
||||
for (const e of edges) {
|
||||
if (e.from !== e.to) fullAdj.get(e.from)?.push(e.to);
|
||||
}
|
||||
|
||||
function dfs(u: string): void {
|
||||
color.set(u, GRAY);
|
||||
for (const v of fullAdj.get(u) ?? []) {
|
||||
const c = color.get(v) ?? WHITE;
|
||||
if (c === GRAY) {
|
||||
backEdges.add(`${u}->${v}`);
|
||||
} else if (c === WHITE) {
|
||||
dfs(v);
|
||||
}
|
||||
}
|
||||
color.set(u, BLACK);
|
||||
}
|
||||
|
||||
if (ids.has(START_ID)) dfs(START_ID);
|
||||
for (const id of ids) {
|
||||
if ((color.get(id) ?? WHITE) === WHITE) dfs(id);
|
||||
}
|
||||
return backEdges;
|
||||
}
|
||||
|
||||
function buildDagAdjacency(
|
||||
ids: Set<string>,
|
||||
edges: readonly WorkflowGraphEdge[],
|
||||
backEdges: Set<string>,
|
||||
): Map<string, string[]> {
|
||||
const adj = new Map<string, string[]>();
|
||||
for (const id of ids) adj.set(id, []);
|
||||
for (const e of edges) {
|
||||
if (e.from === e.to) continue;
|
||||
if (backEdges.has(`${e.from}->${e.to}`)) continue;
|
||||
adj.get(e.from)?.push(e.to);
|
||||
}
|
||||
return adj;
|
||||
}
|
||||
|
||||
function computeInDegrees(ids: Set<string>, adj: Map<string, string[]>): Map<string, number> {
|
||||
const inDegree = new Map<string, number>();
|
||||
for (const id of ids) inDegree.set(id, 0);
|
||||
for (const id of ids) {
|
||||
for (const next of adj.get(id) ?? []) {
|
||||
inDegree.set(next, (inDegree.get(next) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return inDegree;
|
||||
}
|
||||
|
||||
function relaxLongestPathNeighbors(
|
||||
cur: string,
|
||||
curRank: number,
|
||||
adj: Map<string, string[]>,
|
||||
rank: Map<string, number>,
|
||||
inDegree: Map<string, number>,
|
||||
queue: string[],
|
||||
): void {
|
||||
for (const next of adj.get(cur) ?? []) {
|
||||
const prevRank = rank.get(next) ?? 0;
|
||||
if (curRank + 1 > prevRank) rank.set(next, curRank + 1);
|
||||
const deg = (inDegree.get(next) ?? 1) - 1;
|
||||
inDegree.set(next, deg);
|
||||
if (deg === 0) queue.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
function longestPathRanks(ids: Set<string>, adj: Map<string, string[]>): Map<string, number> {
|
||||
const inDegree = computeInDegrees(ids, adj);
|
||||
const rank = new Map<string, number>();
|
||||
const queue: string[] = [];
|
||||
for (const id of ids) {
|
||||
if ((inDegree.get(id) ?? 0) === 0) {
|
||||
queue.push(id);
|
||||
rank.set(id, 0);
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.shift();
|
||||
if (cur === undefined) break;
|
||||
relaxLongestPathNeighbors(cur, rank.get(cur) ?? 0, adj, rank, inDegree, queue);
|
||||
}
|
||||
return rank;
|
||||
}
|
||||
|
||||
function compareLayerNodes(a: string, b: string): number {
|
||||
if (a === START_ID) return -1;
|
||||
if (b === START_ID) return 1;
|
||||
if (a === END_ID) return 1;
|
||||
if (b === END_ID) return -1;
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
function ranksToLayers(rank: Map<string, number>): string[][] {
|
||||
const maxRank = Math.max(...[...rank.values()], 0);
|
||||
const layers: string[][] = [];
|
||||
for (let r = 0; r <= maxRank; r++) layers.push([]);
|
||||
for (const [id, r] of rank) layers[r].push(id);
|
||||
for (const layer of layers) layer.sort(compareLayerNodes);
|
||||
return layers.filter((l) => l.length > 0);
|
||||
}
|
||||
|
||||
// ── Strategy 1: Longest-path layering (Sugiyama step 1) ─────────────
|
||||
|
||||
/**
|
||||
* Assign layers via longest path from sources.
|
||||
*
|
||||
* For each node, rank = max(rank(pred) + 1) over all predecessors.
|
||||
* This guarantees that if a -> b (and not b -> a), rank(a) < rank(b).
|
||||
*
|
||||
* Back-edges (cycles) are detected and excluded from ranking:
|
||||
* we first remove edges that create cycles (DFS-based), compute ranks
|
||||
* on the resulting DAG, then the removed edges become feedback edges.
|
||||
*/
|
||||
function computeLayersLongestPath(edges: readonly WorkflowGraphEdge[]): string[][] {
|
||||
const ids = collectNodeIds(edges);
|
||||
const backEdges = detectBackEdges(ids, edges);
|
||||
const adj = buildDagAdjacency(ids, edges, backEdges);
|
||||
const rank = longestPathRanks(ids, adj);
|
||||
return ranksToLayers(rank);
|
||||
}
|
||||
|
||||
// ── Shared helpers ──────────────────────────────────────────────────
|
||||
|
||||
function buildRoleNode(
|
||||
id: string,
|
||||
pos: { x: number; y: number },
|
||||
@@ -137,102 +211,169 @@ function buildTerminalNode(
|
||||
};
|
||||
}
|
||||
|
||||
function computeLayout(input: LayoutInput): LayoutResult {
|
||||
const spine = extractSpine(input.edges);
|
||||
type EdgeLayoutContext = {
|
||||
rank: Map<string, number>;
|
||||
nodePositions: Map<string, { x: number; y: number; w: number; h: number }>;
|
||||
centerX: number;
|
||||
routedCountByTarget: Map<string, number>;
|
||||
};
|
||||
|
||||
function computeEdgeLabelPosition(
|
||||
e: WorkflowGraphEdge,
|
||||
ctx: EdgeLayoutContext,
|
||||
isFeedback: boolean,
|
||||
isSkipForward: boolean,
|
||||
isSelfLoop: boolean,
|
||||
): { labelX: number | null; labelY: number | null; feedbackSide: "right" | "left" | null } {
|
||||
const sourcePos = ctx.nodePositions.get(e.from);
|
||||
const targetPos = ctx.nodePositions.get(e.to);
|
||||
if (sourcePos === undefined || targetPos === undefined) {
|
||||
return { labelX: null, labelY: null, feedbackSide: null };
|
||||
}
|
||||
|
||||
if (isFeedback || isSkipForward) {
|
||||
const count = ctx.routedCountByTarget.get(e.to) ?? 0;
|
||||
ctx.routedCountByTarget.set(e.to, count + 1);
|
||||
const feedbackSide = count % 2 === 0 ? "right" : "left";
|
||||
const offsetX =
|
||||
feedbackSide === "right"
|
||||
? ctx.centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X
|
||||
: ctx.centerX - ROLE_NODE_WIDTH / 2 - FEEDBACK_OFFSET_X;
|
||||
const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2;
|
||||
return { labelX: offsetX, labelY: midY, feedbackSide };
|
||||
}
|
||||
|
||||
if (isSelfLoop) {
|
||||
return { labelX: null, labelY: null, feedbackSide: null };
|
||||
}
|
||||
|
||||
const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2;
|
||||
return { labelX: ctx.centerX, labelY: midY, feedbackSide: null };
|
||||
}
|
||||
|
||||
function buildConditionEdge(e: WorkflowGraphEdge, ctx: EdgeLayoutContext): Edge {
|
||||
const isFallback = e.condition === "FALLBACK";
|
||||
const isSelfLoop = e.from === e.to;
|
||||
const sourceRank = ctx.rank.get(e.from) ?? 0;
|
||||
const targetRank = ctx.rank.get(e.to) ?? 0;
|
||||
const isFeedback = !isSelfLoop && targetRank <= sourceRank;
|
||||
const isSkipForward = !isSelfLoop && !isFeedback && targetRank - sourceRank > 1;
|
||||
const routed = isFeedback || isSkipForward;
|
||||
|
||||
const { labelX, labelY, feedbackSide } = computeEdgeLabelPosition(
|
||||
e,
|
||||
ctx,
|
||||
isFeedback,
|
||||
isSkipForward,
|
||||
isSelfLoop,
|
||||
);
|
||||
|
||||
return {
|
||||
id: edgeKey(e),
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
sourceHandle: routed ? (feedbackSide === "left" ? "left-out" : "right-out") : "bottom-out",
|
||||
targetHandle: routed ? (feedbackSide === "left" ? "left-in" : "right-in") : "top-in",
|
||||
type: "condition",
|
||||
data: {
|
||||
condition: e.condition,
|
||||
conditionDescription: e.conditionDescription,
|
||||
isFallback,
|
||||
isFeedback: routed,
|
||||
isSelfLoop,
|
||||
feedbackSide,
|
||||
labelX,
|
||||
labelY,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const LAYER_H_GAP = 40;
|
||||
|
||||
type NodePosition = { x: number; y: number; w: number; h: number };
|
||||
|
||||
function layerIndexRank(layers: string[][]): Map<string, number> {
|
||||
const rank = new Map<string, number>();
|
||||
for (let i = 0; i < spine.length; i++) {
|
||||
rank.set(spine[i], i);
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
for (const id of layers[i]) rank.set(id, i);
|
||||
}
|
||||
return rank;
|
||||
}
|
||||
|
||||
// Position nodes along a vertical spine, centered horizontally
|
||||
const centerX = ROLE_NODE_WIDTH / 2; // left edge at x=0, center at width/2
|
||||
const nodePositions = new Map<string, { x: number; y: number; w: number; h: number }>();
|
||||
|
||||
let y = 0;
|
||||
for (const id of spine) {
|
||||
const size = nodeSize(id);
|
||||
// Center-align all nodes on the spine
|
||||
const x = centerX - size.width / 2;
|
||||
nodePositions.set(id, { x, y, w: size.width, h: size.height });
|
||||
y += size.height + LAYER_GAP;
|
||||
}
|
||||
|
||||
// Build nodes
|
||||
const nodes: Node[] = [];
|
||||
for (const id of spine) {
|
||||
const pos = nodePositions.get(id);
|
||||
if (pos === undefined) continue;
|
||||
const state = input.nodeStates.get(id) ?? "default";
|
||||
if (id === START_ID || id === END_ID) {
|
||||
nodes.push(buildTerminalNode(id, { x: pos.x, y: pos.y }, state));
|
||||
} else {
|
||||
nodes.push(buildRoleNode(id, { x: pos.x, y: pos.y }, input.roles, state));
|
||||
}
|
||||
}
|
||||
|
||||
// Build edges with label positions
|
||||
// For feedback edges (target rank < source rank), we'll compute label at midpoint
|
||||
// of the right-side arc. The actual SVG path is drawn by ConditionEdge component.
|
||||
// Track feedback edge count per target node for alternating sides
|
||||
const feedbackCountByTarget = new Map<string, number>();
|
||||
const edges: Edge[] = input.edges.map((e) => {
|
||||
const isFallback = e.condition === "FALLBACK";
|
||||
const isSelfLoop = e.from === e.to;
|
||||
const sourceRank = rank.get(e.from) ?? 0;
|
||||
const targetRank = rank.get(e.to) ?? 0;
|
||||
const isFeedback = !isSelfLoop && targetRank <= sourceRank;
|
||||
|
||||
const sourcePos = nodePositions.get(e.from);
|
||||
const targetPos = nodePositions.get(e.to);
|
||||
|
||||
let labelX: number | null = null;
|
||||
let labelY: number | null = null;
|
||||
let feedbackSide: "right" | "left" | null = null;
|
||||
|
||||
if (sourcePos !== undefined && targetPos !== undefined) {
|
||||
if (isFeedback) {
|
||||
// Alternate feedback edges left/right per target node
|
||||
const count = feedbackCountByTarget.get(e.to) ?? 0;
|
||||
feedbackCountByTarget.set(e.to, count + 1);
|
||||
feedbackSide = count % 2 === 0 ? "right" : "left";
|
||||
const offsetX =
|
||||
feedbackSide === "right"
|
||||
? centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X
|
||||
: centerX - ROLE_NODE_WIDTH / 2 - FEEDBACK_OFFSET_X;
|
||||
const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2;
|
||||
labelX = offsetX;
|
||||
labelY = midY;
|
||||
} else if (!isSelfLoop) {
|
||||
// Forward edge: label between source bottom and target top
|
||||
const midX = centerX;
|
||||
const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2;
|
||||
labelX = midX;
|
||||
labelY = midY;
|
||||
}
|
||||
// Self-loop: let ReactFlow default handle it
|
||||
}
|
||||
|
||||
return {
|
||||
id: edgeKey(e),
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
type: "condition",
|
||||
data: {
|
||||
condition: e.condition,
|
||||
conditionDescription: e.conditionDescription,
|
||||
isFallback,
|
||||
isFeedback,
|
||||
isSelfLoop,
|
||||
feedbackSide,
|
||||
labelX,
|
||||
labelY,
|
||||
},
|
||||
};
|
||||
function computeLayerWidths(layers: string[][], hGap: number): number[] {
|
||||
return layers.map((layer) => {
|
||||
let w = 0;
|
||||
for (const id of layer) w += nodeSize(id).width;
|
||||
return w + (layer.length - 1) * hGap;
|
||||
});
|
||||
}
|
||||
|
||||
function layoutNodePositions(
|
||||
layers: string[][],
|
||||
layerWidths: number[],
|
||||
centerX: number,
|
||||
hGap: number,
|
||||
): Map<string, NodePosition> {
|
||||
const nodePositions = new Map<string, NodePosition>();
|
||||
let y = 0;
|
||||
for (let li = 0; li < layers.length; li++) {
|
||||
const layer = layers[li];
|
||||
let x = centerX - layerWidths[li] / 2;
|
||||
let maxH = 0;
|
||||
for (const id of layer) {
|
||||
const size = nodeSize(id);
|
||||
nodePositions.set(id, { x, y, w: size.width, h: size.height });
|
||||
x += size.width + hGap;
|
||||
if (size.height > maxH) maxH = size.height;
|
||||
}
|
||||
y += maxH + LAYER_GAP;
|
||||
}
|
||||
return nodePositions;
|
||||
}
|
||||
|
||||
function buildLayoutNodes(
|
||||
layers: string[][],
|
||||
nodePositions: Map<string, NodePosition>,
|
||||
input: LayoutInput,
|
||||
): Node[] {
|
||||
const nodes: Node[] = [];
|
||||
for (const layer of layers) {
|
||||
for (const id of layer) {
|
||||
const pos = nodePositions.get(id);
|
||||
if (pos === undefined) continue;
|
||||
const state = input.nodeStates.get(id) ?? "default";
|
||||
const xy = { x: pos.x, y: pos.y };
|
||||
if (id === START_ID || id === END_ID) {
|
||||
nodes.push(buildTerminalNode(id, xy, state));
|
||||
} else {
|
||||
nodes.push(buildRoleNode(id, xy, input.roles, state));
|
||||
}
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// ── Longest-path layout (uses same edge-building as before) ─────────
|
||||
|
||||
function computeLayoutLongestPath(input: LayoutInput): LayoutResult {
|
||||
const layers = computeLayersLongestPath(input.edges);
|
||||
const rank = layerIndexRank(layers);
|
||||
const layerWidths = computeLayerWidths(layers, LAYER_H_GAP);
|
||||
const centerX = Math.max(...layerWidths, ROLE_NODE_WIDTH) / 2;
|
||||
const nodePositions = layoutNodePositions(layers, layerWidths, centerX, LAYER_H_GAP);
|
||||
const nodes = buildLayoutNodes(layers, nodePositions, input);
|
||||
const edgeCtx: EdgeLayoutContext = {
|
||||
rank,
|
||||
nodePositions,
|
||||
centerX,
|
||||
routedCountByTarget: new Map<string, number>(),
|
||||
};
|
||||
const edges: Edge[] = input.edges.map((e) => buildConditionEdge(e, edgeCtx));
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// ── Public hook ─────────────────────────────────────────────────────
|
||||
|
||||
export function useLayout(input: LayoutInput): LayoutResult {
|
||||
return useMemo(() => computeLayout(input), [input]);
|
||||
return useMemo(() => computeLayoutLongestPath(input), [input]);
|
||||
}
|
||||
|
||||
@@ -32,16 +32,16 @@ const edgeTypes: EdgeTypes = {
|
||||
condition: ConditionEdge,
|
||||
};
|
||||
|
||||
function handleRoleNodeClick(onRoleClick: (roleName: string) => void, node: Node): void {
|
||||
if (node.type !== "role") return;
|
||||
onRoleClick(node.id);
|
||||
function handleNodeClick(onNodeClick: (nodeId: string) => void, node: Node): void {
|
||||
if (node.type !== "role" && node.type !== "terminal") return;
|
||||
onNodeClick(node.id);
|
||||
}
|
||||
|
||||
export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) {
|
||||
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
|
||||
|
||||
const onNodeClickHandler: OnNodeClick | undefined =
|
||||
onNodeClick !== null ? (_e, node) => handleRoleNodeClick(onNodeClick, node) : undefined;
|
||||
onNodeClick !== null ? (_e, node) => handleNodeClick(onNodeClick, node) : undefined;
|
||||
|
||||
const styledEdges = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -1,174 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { WorkflowDetail } from "../api.ts";
|
||||
import { getWorkflowDetail, listWorkflows } from "../api.ts";
|
||||
import { listWorkflows } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
||||
|
||||
type Props = {
|
||||
client: string;
|
||||
onSelect: (name: string) => void;
|
||||
};
|
||||
|
||||
type DetailCacheEntry =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "ok"; detail: WorkflowDetail };
|
||||
|
||||
function versionCount(detail: WorkflowDetail): number {
|
||||
return detail.history.length + 1;
|
||||
}
|
||||
|
||||
function ExpandedWorkflowBody({
|
||||
cacheEntry,
|
||||
staticNodeStates,
|
||||
}: {
|
||||
cacheEntry: DetailCacheEntry | undefined;
|
||||
staticNodeStates: Map<string, NodeState>;
|
||||
}) {
|
||||
if (cacheEntry === undefined || cacheEntry.status === "loading") {
|
||||
return (
|
||||
<p className="text-sm py-2" style={{ color: "var(--color-text-muted)" }}>
|
||||
Loading workflow details...
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (cacheEntry.status === "error") {
|
||||
return (
|
||||
<p className="text-sm py-2" style={{ color: "var(--color-error)" }}>
|
||||
{cacheEntry.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const { detail } = cacheEntry;
|
||||
const descriptor = detail.descriptor;
|
||||
const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
|
||||
const vc = versionCount(detail);
|
||||
|
||||
const hasGraph = descriptor !== null && edgeCount > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pt-3 border-t flex gap-4"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<div className="space-y-3 shrink-0" style={{ minWidth: 200, maxWidth: 280 }}>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
{detail.name}
|
||||
</p>
|
||||
<p className="text-xs mt-1 mb-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
Hash
|
||||
</p>
|
||||
<code className="text-xs font-mono block" style={{ color: "var(--color-accent)" }}>
|
||||
{detail.hash}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||
{vc} version{vc !== 1 ? "s" : ""}
|
||||
</p>
|
||||
<div>
|
||||
<p className="text-xs mb-1 font-medium" style={{ color: "var(--color-text-muted)" }}>
|
||||
Description
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--color-text)" }}>
|
||||
{descriptor !== null && descriptor.description !== ""
|
||||
? descriptor.description
|
||||
: descriptor !== null
|
||||
? "—"
|
||||
: "No descriptor available for this workflow version."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{hasGraph ? (
|
||||
<div
|
||||
className="rounded-lg border overflow-hidden flex-1"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-bg)", minHeight: 500 }}
|
||||
>
|
||||
<div
|
||||
className="px-3 py-2 text-xs flex justify-between items-center"
|
||||
style={{ color: "var(--color-text-muted)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<span className="font-mono">Workflow graph</span>
|
||||
<span>
|
||||
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 600, width: "100%" }}>
|
||||
<WorkflowGraph
|
||||
graph={descriptor.graph}
|
||||
roles={descriptor.roles}
|
||||
nodeStates={staticNodeStates}
|
||||
onNodeClick={null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowList({ client }: Props) {
|
||||
export function WorkflowList({ client, onSelect }: Props) {
|
||||
const { status, data, error } = useFetch(() => listWorkflows(client), [client]);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const [detailsByName, setDetailsByName] = useState<Map<string, DetailCacheEntry>>(
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
const staticNodeStates = useMemo(() => new Map<string, NodeState>(), []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset expansion when switching clients
|
||||
useEffect(() => {
|
||||
setExpanded(new Set());
|
||||
setDetailsByName(new Map());
|
||||
}, [client]);
|
||||
|
||||
const ensureDetailLoaded = useCallback(
|
||||
(name: string) => {
|
||||
setDetailsByName((prev) => {
|
||||
const cur = prev.get(name);
|
||||
if (cur !== undefined && (cur.status === "ok" || cur.status === "loading")) {
|
||||
return prev;
|
||||
}
|
||||
return new Map(prev).set(name, { status: "loading" });
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const detail = await getWorkflowDetail(client, name);
|
||||
setDetailsByName((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(name, { status: "ok", detail });
|
||||
return next;
|
||||
});
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
setDetailsByName((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(name, { status: "error", message });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
[client],
|
||||
);
|
||||
|
||||
function toggleExpanded(name: string) {
|
||||
const wasExpanded = expanded.has(name);
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (!wasExpanded) {
|
||||
ensureDetailLoaded(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "loading")
|
||||
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
|
||||
@@ -183,58 +22,34 @@ export function WorkflowList({ client }: Props) {
|
||||
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{workflows.map((w) => {
|
||||
const isOpen = expanded.has(w.name);
|
||||
return (
|
||||
<div
|
||||
key={w.name}
|
||||
className="rounded-lg border overflow-hidden"
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpanded(w.name)}
|
||||
className="w-full text-left p-4 flex items-start justify-between gap-3 hover:opacity-90"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-xs font-mono"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{isOpen ? "▼" : "▶"}
|
||||
</span>
|
||||
<span className="font-medium">{w.name}</span>
|
||||
</div>
|
||||
<code
|
||||
className="text-xs mt-1 block font-mono truncate"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
{w.hash !== null ? w.hash : "—"}
|
||||
</code>
|
||||
{w.timestamp !== null ? (
|
||||
<span
|
||||
className="text-xs mt-1 block"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Updated {new Date(w.timestamp).toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="px-4 pb-4">
|
||||
<ExpandedWorkflowBody
|
||||
cacheEntry={detailsByName.get(w.name)}
|
||||
staticNodeStates={staticNodeStates}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{workflows.map((w) => (
|
||||
<button
|
||||
key={w.name}
|
||||
type="button"
|
||||
onClick={() => onSelect(w.name)}
|
||||
className="w-full text-left p-4 rounded-lg border hover:opacity-90"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{w.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<code
|
||||
className="text-xs mt-1 block font-mono truncate"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
{w.hash !== null ? w.hash : "—"}
|
||||
</code>
|
||||
{w.timestamp !== null ? (
|
||||
<span className="text-xs mt-1 block" style={{ color: "var(--color-text-muted)" }}>
|
||||
Updated {new Date(w.timestamp).toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ type HashRoute = {
|
||||
view: View;
|
||||
client: string | null;
|
||||
threadId: string | null;
|
||||
workflowName: string | null;
|
||||
};
|
||||
|
||||
function parseHash(hash: string): HashRoute {
|
||||
@@ -19,6 +20,7 @@ function parseHash(hash: string): HashRoute {
|
||||
view: parts[0] as View,
|
||||
client: null,
|
||||
threadId: parts[0] === "threads" && parts.length > 1 ? parts.slice(1).join("/") : null,
|
||||
workflowName: parts[0] === "workflows" && parts.length > 1 ? parts.slice(1).join("/") : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,13 +29,17 @@ function parseHash(hash: string): HashRoute {
|
||||
const viewPart = parts[1] ?? "threads";
|
||||
const view: View = viewPart === "workflows" ? "workflows" : "threads";
|
||||
const threadId = view === "threads" && parts.length > 2 ? parts.slice(2).join("/") : null;
|
||||
const workflowName = view === "workflows" && parts.length > 2 ? parts.slice(2).join("/") : null;
|
||||
|
||||
return { view, client, threadId };
|
||||
return { view, client, threadId, workflowName };
|
||||
}
|
||||
|
||||
function buildHash(route: HashRoute): string {
|
||||
const prefix = route.client ? `${route.client}/` : "";
|
||||
if (route.view === "workflows") {
|
||||
if (route.workflowName !== null) {
|
||||
return `#${prefix}workflows/${route.workflowName}`;
|
||||
}
|
||||
return `#${prefix}workflows`;
|
||||
}
|
||||
if (route.threadId !== null) {
|
||||
@@ -46,9 +52,11 @@ export function useHashRoute(): {
|
||||
view: View;
|
||||
client: string | null;
|
||||
threadId: string | null;
|
||||
workflowName: string | null;
|
||||
setView: (v: View) => void;
|
||||
setClient: (a: string | null) => void;
|
||||
setThreadId: (id: string | null) => void;
|
||||
setWorkflowName: (name: string | null) => void;
|
||||
} {
|
||||
const [route, setRoute] = useState<HashRoute>(() => parseHash(window.location.hash));
|
||||
|
||||
@@ -67,17 +75,25 @@ export function useHashRoute(): {
|
||||
}, []);
|
||||
|
||||
const setView = useCallback(
|
||||
(v: View) => navigate({ view: v, client: route.client, threadId: null }),
|
||||
(v: View) => navigate({ view: v, client: route.client, threadId: null, workflowName: null }),
|
||||
[navigate, route.client],
|
||||
);
|
||||
|
||||
const setClient = useCallback(
|
||||
(a: string | null) => navigate({ view: route.view, client: a, threadId: null }),
|
||||
(a: string | null) =>
|
||||
navigate({ view: route.view, client: a, threadId: null, workflowName: null }),
|
||||
[navigate, route.view],
|
||||
);
|
||||
|
||||
const setThreadId = useCallback(
|
||||
(id: string | null) => navigate({ view: "threads", client: route.client, threadId: id }),
|
||||
(id: string | null) =>
|
||||
navigate({ view: "threads", client: route.client, threadId: id, workflowName: null }),
|
||||
[navigate, route.client],
|
||||
);
|
||||
|
||||
const setWorkflowName = useCallback(
|
||||
(name: string | null) =>
|
||||
navigate({ view: "workflows", client: route.client, threadId: null, workflowName: name }),
|
||||
[navigate, route.client],
|
||||
);
|
||||
|
||||
@@ -85,8 +101,10 @@ export function useHashRoute(): {
|
||||
view: route.view,
|
||||
client: route.client,
|
||||
threadId: route.threadId,
|
||||
workflowName: route.workflowName,
|
||||
setView,
|
||||
setClient,
|
||||
setThreadId,
|
||||
setWorkflowName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,66 @@
|
||||
# @uncaged/workflow-execute
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [f74b482]
|
||||
- Updated dependencies [f74b482]
|
||||
- @uncaged/workflow-util@0.5.0-alpha.4
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.4
|
||||
- @uncaged/workflow-reactor@0.5.0-alpha.4
|
||||
- @uncaged/workflow-register@0.5.0-alpha.4
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.3
|
||||
- @uncaged/workflow-reactor@0.5.0-alpha.3
|
||||
- @uncaged/workflow-register@0.5.0-alpha.3
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||
- @uncaged/workflow-util@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.2
|
||||
- @uncaged/workflow-reactor@0.5.0-alpha.2
|
||||
- @uncaged/workflow-register@0.5.0-alpha.2
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||
- @uncaged/workflow-util@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.1
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||
- @uncaged/workflow-reactor@0.5.0-alpha.1
|
||||
- @uncaged/workflow-register@0.5.0-alpha.1
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||
- @uncaged/workflow-util@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.0
|
||||
- @uncaged/workflow-reactor@0.5.0-alpha.0
|
||||
- @uncaged/workflow-register@0.5.0-alpha.0
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||
- @uncaged/workflow-util@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-execute",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
|
||||
@@ -3,10 +3,7 @@ import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { createServer, type Socket } from "node:net";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import {
|
||||
ensureUncagedWorkflowSymlink,
|
||||
importWorkflowBundleModule,
|
||||
} from "@uncaged/workflow-register";
|
||||
import { importWorkflowBundleModule } from "@uncaged/workflow-register";
|
||||
import type { RoleOutput, WorkflowFn } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
createLogger,
|
||||
@@ -365,7 +362,6 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureUncagedWorkflowSymlink(storageRoot);
|
||||
// Dynamic import required: user bundle path resolved at runtime
|
||||
const modUnknown: unknown = await importWorkflowBundleModule(bundlePath);
|
||||
const modRec = modUnknown as Record<string, unknown>;
|
||||
|
||||
@@ -1,48 +1,22 @@
|
||||
export { createWorkflow } from "./engine/create-workflow.js";
|
||||
export { executeThread } from "./engine/engine.js";
|
||||
export {
|
||||
FORK_BRANCH_ROLE,
|
||||
prepareCasFork,
|
||||
tryParseWorkflowResultRecord,
|
||||
walkStateFramesNewestFirst,
|
||||
} from "./engine/fork-thread.js";
|
||||
export { garbageCollectCas } from "./engine/gc.js";
|
||||
export { createThreadPauseGate } from "./engine/thread-pause-gate.js";
|
||||
export type {
|
||||
ThreadHistoryEntry,
|
||||
ThreadIndex,
|
||||
ThreadIndexEntry,
|
||||
} from "./engine/threads-index.js";
|
||||
export {
|
||||
appendThreadHistoryEntry,
|
||||
getBundleDir,
|
||||
readThreadsIndex,
|
||||
removeThreadEntry,
|
||||
removeThreadHistoryEntries,
|
||||
upsertThreadEntry,
|
||||
writeThreadsIndex,
|
||||
} from "./engine/threads-index.js";
|
||||
export type {
|
||||
CasForkPlan,
|
||||
ChainState,
|
||||
ExecuteThreadIo,
|
||||
ExecuteThreadOptions,
|
||||
ForkContinuationOptions,
|
||||
GcResult,
|
||||
PrefilledDiskStep,
|
||||
SupervisorDecision,
|
||||
ThreadPauseGate,
|
||||
} from "./engine/types.js";
|
||||
export { EMPTY_CHAIN_STATE } from "./engine/types.js";
|
||||
export type { GcResult } from "./engine/types.js";
|
||||
export { getWorkerHostScriptPath } from "./engine/worker-entry-path.js";
|
||||
export type { ExtractFn, LlmError, LlmExtractArgs } from "./extract/index.js";
|
||||
export {
|
||||
createExtract,
|
||||
extractFunctionToolFromZodSchema,
|
||||
llmErrorToCause,
|
||||
llmExtract,
|
||||
} from "./extract/index.js";
|
||||
export { createExtract } from "./extract/index.js";
|
||||
export { type WorkflowAdapterOptions, workflowAdapter } from "./workflow-adapter.js";
|
||||
|
||||
/** @deprecated Use {@link workflowAdapter} instead. */
|
||||
export { type WorkflowAsAgentOptions, workflowAsAgent } from "./workflow-as-agent.js";
|
||||
|
||||
@@ -69,7 +69,7 @@ async function resolveWorkflowBundle(workflowName: string, storageRoot: string,
|
||||
}
|
||||
|
||||
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
|
||||
const bundleExportsResult = await extractBundleExports(bundlePath, { storageRoot });
|
||||
const bundleExportsResult = await extractBundleExports(bundlePath);
|
||||
if (!bundleExportsResult.ok) {
|
||||
throw new Error(String(bundleExportsResult.error));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# @uncaged/workflow-gateway
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-gateway",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DurableObject } from "cloudflare:workers";
|
||||
import { parseWsRequestJson, parseWsResponseJson, type WsResponse } from "./ws-protocol.js";
|
||||
|
||||
type ClientSocketEnv = {
|
||||
GATEWAY_SECRET: string;
|
||||
WORKFLOW_DASHBOARD_SECRET: string;
|
||||
};
|
||||
|
||||
export const CLIENT_SOCKET_INTERNAL_STATUS_PATH = "/internal/client-socket/status";
|
||||
@@ -37,7 +37,7 @@ export class ClientSocket extends DurableObject<ClientSocketEnv> {
|
||||
|
||||
private requireAuth(request: Request): Response | null {
|
||||
const auth = request.headers.get("Authorization");
|
||||
if (auth !== `Bearer ${this.env.GATEWAY_SECRET}`) {
|
||||
if (auth !== `Bearer ${this.env.WORKFLOW_DASHBOARD_SECRET}`) {
|
||||
return jsonResponse(401, { error: "unauthorized" });
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -13,8 +13,7 @@ export { ClientSocket };
|
||||
type Env = {
|
||||
Bindings: {
|
||||
ENDPOINTS: KVNamespace;
|
||||
GATEWAY_SECRET: string;
|
||||
DASHBOARD_API_KEY: string;
|
||||
WORKFLOW_DASHBOARD_SECRET: string;
|
||||
CLIENT_SOCKET: DurableObjectNamespace<ClientSocket>;
|
||||
};
|
||||
};
|
||||
@@ -40,7 +39,7 @@ function checkDashboardAuth(c: {
|
||||
const bearer = c.req.header("Authorization")?.replace("Bearer ", "");
|
||||
const query = c.req.query("key");
|
||||
const key = bearer ?? query;
|
||||
return key === c.env.DASHBOARD_API_KEY;
|
||||
return key === c.env.WORKFLOW_DASHBOARD_SECRET;
|
||||
}
|
||||
|
||||
function isLocalClientUrl(url: string): boolean {
|
||||
@@ -153,7 +152,7 @@ async function fetchClientSocketStatus(
|
||||
const resp = await stub.fetch(
|
||||
new Request(`https://do${CLIENT_SOCKET_INTERNAL_STATUS_PATH}`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${env.GATEWAY_SECRET}` },
|
||||
headers: { Authorization: `Bearer ${env.WORKFLOW_DASHBOARD_SECRET}` },
|
||||
}),
|
||||
);
|
||||
if (!resp.ok) {
|
||||
@@ -184,14 +183,14 @@ function endpointStatusFromKvAndDo(record: EndpointRecord, doConnected: boolean
|
||||
// ── Health ──────────────────────────────────────────────────────────
|
||||
app.get("/healthz", (c) => c.json({ ok: true }));
|
||||
|
||||
// ── Client reverse WebSocket (GATEWAY_SECRET query param) ────────────
|
||||
// ── Client reverse WebSocket (WORKFLOW_DASHBOARD_SECRET query param) ────────────
|
||||
app.get("/ws/connect", async (c) => {
|
||||
const secret = c.req.query("secret");
|
||||
const name = c.req.query("name");
|
||||
if (name === undefined || name === "") {
|
||||
return c.json({ error: "name required" }, 400);
|
||||
}
|
||||
if (secret !== c.env.GATEWAY_SECRET) {
|
||||
if (secret !== c.env.WORKFLOW_DASHBOARD_SECRET) {
|
||||
return c.json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
if (c.req.header("Upgrade") !== "websocket") {
|
||||
@@ -202,7 +201,7 @@ app.get("/ws/connect", async (c) => {
|
||||
return stub.fetch(c.req.raw);
|
||||
});
|
||||
|
||||
// ── Gateway management (GATEWAY_SECRET auth) ────────────────────────
|
||||
// ── Gateway management (WORKFLOW_DASHBOARD_SECRET auth) ────────────────────────
|
||||
const gateway = new Hono<Env>();
|
||||
|
||||
gateway.post("/register", async (c) => {
|
||||
@@ -217,7 +216,7 @@ gateway.post("/register", async (c) => {
|
||||
if (!name || !url) {
|
||||
return c.json({ error: "name and url required" }, 400);
|
||||
}
|
||||
if (secret !== c.env.GATEWAY_SECRET) {
|
||||
if (secret !== c.env.WORKFLOW_DASHBOARD_SECRET) {
|
||||
return c.json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
|
||||
@@ -242,7 +241,7 @@ gateway.post("/register", async (c) => {
|
||||
|
||||
gateway.delete("/register/:name", async (c) => {
|
||||
const auth = c.req.header("Authorization");
|
||||
if (auth !== `Bearer ${c.env.GATEWAY_SECRET}`) {
|
||||
if (auth !== `Bearer ${c.env.WORKFLOW_DASHBOARD_SECRET}`) {
|
||||
return c.json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
|
||||
@@ -305,7 +304,12 @@ app.all("/api/clients/:client/*", async (c) => {
|
||||
headers: forwardRecord,
|
||||
body: bodyStr,
|
||||
};
|
||||
const proxyResp = await fetchThroughClientSocket(c.env, client, c.env.GATEWAY_SECRET, wsRequest);
|
||||
const proxyResp = await fetchThroughClientSocket(
|
||||
c.env,
|
||||
client,
|
||||
c.env.WORKFLOW_DASHBOARD_SECRET,
|
||||
wsRequest,
|
||||
);
|
||||
if (proxyResp.status !== 503) {
|
||||
return new Response(proxyResp.body, {
|
||||
status: proxyResp.status,
|
||||
|
||||
@@ -17,4 +17,4 @@ new_sqlite_classes = ["AgentSocket"]
|
||||
tag = "rename-agent-to-client"
|
||||
renamed_classes = [{ from = "AgentSocket", to = "ClientSocket" }]
|
||||
|
||||
# GATEWAY_SECRET is set via `wrangler secret put`
|
||||
# WORKFLOW_DASHBOARD_SECRET is set via `wrangler secret put`
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
# @uncaged/workflow-protocol
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f74b482: fix: correct internal dependency versions for prerelease
|
||||
- f74b482: fix: use npm publish with pinned deps instead of bun publish (workspace:^ resolution bug)
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix: use npm publish with pinned deps instead of bun publish (workspace:^ resolution bug)
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix: correct internal dependency versions for prerelease
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- feat: AgentFn<Opt> type boundary and createAgentAdapter bridging function (RFC #252)
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-protocol",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
|
||||
@@ -13,6 +13,7 @@ export type {
|
||||
AdapterFn,
|
||||
AdvanceOutcome,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
|
||||
@@ -24,6 +24,7 @@ export type WorkflowRoleSchema = Record<string, unknown>;
|
||||
|
||||
export type WorkflowRoleDescriptor = {
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
schema: WorkflowRoleSchema;
|
||||
};
|
||||
|
||||
@@ -151,6 +152,12 @@ export type RoleFn<T> = (ctx: ThreadContext, runtime: WorkflowRuntime) => Promis
|
||||
|
||||
export type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
|
||||
|
||||
/**
|
||||
* Core agent function. Input is always {@link ThreadContext}, output is always string.
|
||||
* `Opt` captures agent-specific structured options (required second argument).
|
||||
*/
|
||||
export type AgentFn<Opt> = (ctx: ThreadContext, options: Opt) => Promise<string>;
|
||||
|
||||
export type AdapterBinding = {
|
||||
adapter: AdapterFn;
|
||||
overrides: Partial<Record<string, AdapterFn>> | null;
|
||||
@@ -172,7 +179,6 @@ export type RoleDefinition<Meta extends Record<string, unknown>> = {
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
schema: z.ZodType<Meta>;
|
||||
extractRefs: ((meta: Meta) => string[]) | null;
|
||||
};
|
||||
|
||||
export type Moderator<M extends RoleMeta> = (
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# @uncaged/workflow-reactor
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f74b482]
|
||||
- Updated dependencies [f74b482]
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-reactor",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
|
||||
@@ -3,10 +3,6 @@ export { createThreadReactor } from "./thread-reactor.js";
|
||||
export type {
|
||||
ChatMessage,
|
||||
LlmFn,
|
||||
StructuredToolSpec,
|
||||
ThreadReactorConfig,
|
||||
ThreadReactorFn,
|
||||
ThreadReactorInvokeArgs,
|
||||
ToolCall,
|
||||
ToolDefinition,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# @uncaged/workflow-register
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [f74b482]
|
||||
- Updated dependencies [f74b482]
|
||||
- @uncaged/workflow-util@0.5.0-alpha.4
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||
- @uncaged/workflow-util@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||
- @uncaged/workflow-util@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||
- @uncaged/workflow-util@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||
- @uncaged/workflow-util@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-register",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
|
||||
@@ -35,11 +35,12 @@ export function buildDescriptor<M extends RoleMeta>(
|
||||
): WorkflowDescriptor {
|
||||
const roles: WorkflowDescriptor["roles"] = {};
|
||||
for (const [key, roleDef] of Object.entries(def.roles) as Array<
|
||||
[string, { description: string; schema: z.ZodType }]
|
||||
[string, { description: string; systemPrompt: string; schema: z.ZodType }]
|
||||
>) {
|
||||
const rawJsonSchema = z.toJSONSchema(roleDef.schema) as Record<string, unknown>;
|
||||
roles[key] = {
|
||||
description: roleDef.description,
|
||||
systemPrompt: roleDef.systemPrompt,
|
||||
schema: stripJsonSchemaMeta(rawJsonSchema),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
/**
|
||||
* Dynamic-import a workflow bundle path (see {@link extractBundleExports} — symlink must exist first).
|
||||
* Dynamic-import a workflow bundle path.
|
||||
*/
|
||||
export async function importWorkflowBundleModule(bundlePath: string): Promise<unknown> {
|
||||
return import(pathToFileURL(bundlePath).href);
|
||||
|
||||
@@ -37,9 +37,6 @@ function isAllowedImportSpecifier(spec: string): boolean {
|
||||
if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("file:")) {
|
||||
return false;
|
||||
}
|
||||
if (spec.startsWith("@uncaged/workflow")) {
|
||||
return true;
|
||||
}
|
||||
return isBuiltin(spec);
|
||||
}
|
||||
|
||||
@@ -294,7 +291,7 @@ function validateImportDeclaration(node: ImportDeclaration): string | null {
|
||||
return "only static string import specifiers are allowed";
|
||||
}
|
||||
if (!isAllowedImportSpecifier(spec)) {
|
||||
return `disallowed import specifier "${spec}" (only Node built-ins and @uncaged/workflow-* packages are allowed)`;
|
||||
return `disallowed import specifier "${spec}" (only Node built-ins are allowed; all other dependencies must be bundled)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -309,7 +306,7 @@ function validateExportSource(
|
||||
return staticMessage;
|
||||
}
|
||||
if (!isAllowedImportSpecifier(spec)) {
|
||||
return `${disallowedPrefix} "${spec}" (only Node built-ins and @uncaged/workflow-* packages are allowed)`;
|
||||
return `${disallowedPrefix} "${spec}" (only Node built-ins are allowed; all other dependencies must be bundled)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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,21 +1,15 @@
|
||||
import type { WorkflowFn } from "@uncaged/workflow-protocol";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-util";
|
||||
import { importWorkflowBundleModule } from "./bundle-import-env.js";
|
||||
import { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
|
||||
import type { ExtractBundleExportsOptions, ExtractedBundleExports } from "./types.js";
|
||||
import type { ExtractedBundleExports } from "./types.js";
|
||||
import { validateWorkflowDescriptor } from "./workflow-descriptor.js";
|
||||
|
||||
/** Load a workflow `.esm.js` bundle and read its named exports (`run`, `descriptor`). */
|
||||
export async function extractBundleExports(
|
||||
bundlePath: string,
|
||||
options: ExtractBundleExportsOptions = { storageRoot: null },
|
||||
): Promise<Result<ExtractedBundleExports, string>> {
|
||||
let modUnknown: unknown;
|
||||
try {
|
||||
if (options.storageRoot !== null) {
|
||||
await ensureUncagedWorkflowSymlink(options.storageRoot);
|
||||
}
|
||||
// Dynamic import required: user bundle path resolved at runtime
|
||||
modUnknown = await importWorkflowBundleModule(bundlePath);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
export { buildDescriptor } from "./build-descriptor.js";
|
||||
export { importWorkflowBundleModule } from "./bundle-import-env.js";
|
||||
export { validateWorkflowBundle } from "./bundle-validator.js";
|
||||
export { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
|
||||
export { extractBundleExports } from "./extract-bundle-exports.js";
|
||||
export { stringifyWorkflowDescriptor } from "./generate-descriptor.js";
|
||||
export type {
|
||||
ExtractBundleExportsOptions,
|
||||
ExtractedBundleExports,
|
||||
WorkflowBundleValidationInput,
|
||||
WorkflowDescriptor,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
} from "./types.js";
|
||||
export { validateWorkflowDescriptor } from "./workflow-descriptor.js";
|
||||
|
||||
@@ -20,8 +20,3 @@ export type ExtractedBundleExports = {
|
||||
run: WorkflowFn;
|
||||
descriptor: WorkflowDescriptor;
|
||||
};
|
||||
|
||||
export type ExtractBundleExportsOptions = {
|
||||
/** When set, ensures `node_modules/@uncaged/workflow` exists under this root before import. */
|
||||
storageRoot: string | null;
|
||||
};
|
||||
|
||||
@@ -60,6 +60,30 @@ function validateDescriptorGraph(graphRaw: unknown): Result<WorkflowGraph, strin
|
||||
return ok({ edges });
|
||||
}
|
||||
|
||||
function validateDescriptorRole(
|
||||
roleName: string,
|
||||
specUnknown: unknown,
|
||||
): Result<WorkflowRoleDescriptor, string> {
|
||||
if (specUnknown === null || typeof specUnknown !== "object" || Array.isArray(specUnknown)) {
|
||||
return err(`descriptor.roles.${roleName} must be a non-array object`);
|
||||
}
|
||||
const spec = specUnknown as Record<string, unknown>;
|
||||
const roleDesc = spec.description;
|
||||
if (typeof roleDesc !== "string") {
|
||||
return err(`descriptor.roles.${roleName}.description must be a string`);
|
||||
}
|
||||
const schema = spec.schema;
|
||||
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
|
||||
return err(`descriptor.roles.${roleName}.schema must be a non-array object`);
|
||||
}
|
||||
const systemPrompt = typeof spec.systemPrompt === "string" ? spec.systemPrompt : "";
|
||||
return ok({
|
||||
description: roleDesc,
|
||||
systemPrompt,
|
||||
schema: schema as WorkflowRoleSchema,
|
||||
});
|
||||
}
|
||||
|
||||
export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescriptor, string> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return err("descriptor must be a non-array object");
|
||||
@@ -76,22 +100,11 @@ export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescr
|
||||
|
||||
const roles: Record<string, WorkflowRoleDescriptor> = {};
|
||||
for (const [roleName, specUnknown] of Object.entries(rolesRaw)) {
|
||||
if (specUnknown === null || typeof specUnknown !== "object" || Array.isArray(specUnknown)) {
|
||||
return err(`descriptor.roles.${roleName} must be a non-array object`);
|
||||
const roleResult = validateDescriptorRole(roleName, specUnknown);
|
||||
if (!roleResult.ok) {
|
||||
return roleResult;
|
||||
}
|
||||
const spec = specUnknown as Record<string, unknown>;
|
||||
const roleDesc = spec.description;
|
||||
if (typeof roleDesc !== "string") {
|
||||
return err(`descriptor.roles.${roleName}.description must be a string`);
|
||||
}
|
||||
const schema = spec.schema;
|
||||
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
|
||||
return err(`descriptor.roles.${roleName}.schema must be a non-array object`);
|
||||
}
|
||||
roles[roleName] = {
|
||||
description: roleDesc,
|
||||
schema: schema as WorkflowRoleSchema,
|
||||
};
|
||||
roles[roleName] = roleResult.value;
|
||||
}
|
||||
|
||||
const graphResult = validateDescriptorGraph(root.graph);
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
export type {
|
||||
ExtractBundleExportsOptions,
|
||||
ExtractedBundleExports,
|
||||
WorkflowBundleValidationInput,
|
||||
WorkflowDescriptor,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
} from "./bundle/index.js";
|
||||
export {
|
||||
buildDescriptor,
|
||||
ensureUncagedWorkflowSymlink,
|
||||
extractBundleExports,
|
||||
importWorkflowBundleModule,
|
||||
stringifyWorkflowDescriptor,
|
||||
@@ -21,18 +14,15 @@ export type { ProviderConfig, ResolvedModel } from "./config/index.js";
|
||||
export { resolveModel, splitProviderModelRef } from "./config/index.js";
|
||||
export type {
|
||||
WorkflowConfig,
|
||||
WorkflowHistoryEntry,
|
||||
WorkflowRegistryEntry,
|
||||
WorkflowRegistryFile,
|
||||
} from "./registry/index.js";
|
||||
export {
|
||||
getRegisteredWorkflow,
|
||||
listRegisteredWorkflowNames,
|
||||
parseWorkflowRegistryYaml,
|
||||
readWorkflowRegistry,
|
||||
registerWorkflowVersion,
|
||||
rollbackWorkflowToHistoryHash,
|
||||
stringifyWorkflowRegistryYaml,
|
||||
unregisterWorkflow,
|
||||
workflowRegistryPath,
|
||||
writeWorkflowRegistry,
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
# @uncaged/workflow-runtime
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f74b482]
|
||||
- Updated dependencies [f74b482]
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.1
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as z from "zod/v4";
|
||||
import { collectCasRefs } from "../src/collect-cas-refs.js";
|
||||
|
||||
const phaseSchema = z.object({
|
||||
hash: z.string().meta({ casRef: true }),
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
const plannerMetaSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("planned"),
|
||||
phases: z.array(phaseSchema),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("aborted"),
|
||||
reason: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
describe("collectCasRefs", () => {
|
||||
test("1. flat field with casRef annotation", () => {
|
||||
const schema = z.object({
|
||||
completedPhase: z.string().meta({ casRef: true }),
|
||||
});
|
||||
expect(collectCasRefs(schema, { completedPhase: "BHAAAAAAAAAAA" })).toEqual(["BHAAAAAAAAAAA"]);
|
||||
});
|
||||
|
||||
test("2. plain string without annotation is ignored", () => {
|
||||
const schema = z.object({
|
||||
summary: z.string(),
|
||||
completedPhase: z.string().meta({ casRef: true }),
|
||||
});
|
||||
expect(
|
||||
collectCasRefs(schema, {
|
||||
summary: "done",
|
||||
completedPhase: "BHBBBBBBBBBBB",
|
||||
}),
|
||||
).toEqual(["BHBBBBBBBBBBB"]);
|
||||
});
|
||||
|
||||
test("3. nested array of objects collects each annotated hash", () => {
|
||||
const schema = z.object({
|
||||
phases: z.array(phaseSchema),
|
||||
});
|
||||
expect(
|
||||
collectCasRefs(schema, {
|
||||
phases: [
|
||||
{ hash: "BH11111111111", title: "setup" },
|
||||
{ hash: "BH22222222222", title: "impl" },
|
||||
],
|
||||
}),
|
||||
).toEqual(["BH11111111111", "BH22222222222"]);
|
||||
});
|
||||
|
||||
test("4. discriminatedUnion — planner planned branch", () => {
|
||||
expect(
|
||||
collectCasRefs(plannerMetaSchema, {
|
||||
status: "planned",
|
||||
phases: [
|
||||
{ hash: "BH33333333333", title: "a" },
|
||||
{ hash: "BH44444444444", title: "b" },
|
||||
],
|
||||
}),
|
||||
).toEqual(["BH33333333333", "BH44444444444"]);
|
||||
});
|
||||
|
||||
test("4b. discriminatedUnion — planner aborted branch", () => {
|
||||
expect(
|
||||
collectCasRefs(plannerMetaSchema, {
|
||||
status: "aborted",
|
||||
reason: "missing workspace",
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("5. null and undefined annotated fields are skipped", () => {
|
||||
const schema = z.object({
|
||||
ref: z.string().meta({ casRef: true }).nullable(),
|
||||
optionalRef: z.string().meta({ casRef: true }).optional(),
|
||||
});
|
||||
expect(collectCasRefs(schema, { ref: null, optionalRef: undefined })).toEqual([]);
|
||||
expect(collectCasRefs(schema, { ref: "BH55555555555", optionalRef: undefined })).toEqual([
|
||||
"BH55555555555",
|
||||
]);
|
||||
});
|
||||
|
||||
test("6. mixed annotated and plain fields at multiple levels", () => {
|
||||
const schema = z.object({
|
||||
label: z.string(),
|
||||
phase: z.object({
|
||||
hash: z.string().meta({ casRef: true }),
|
||||
title: z.string(),
|
||||
}),
|
||||
tags: z.array(z.string()),
|
||||
});
|
||||
expect(
|
||||
collectCasRefs(schema, {
|
||||
label: "coder",
|
||||
phase: { hash: "BH66666666666", title: "fix" },
|
||||
tags: ["a", "b"],
|
||||
}),
|
||||
).toEqual(["BH66666666666"]);
|
||||
});
|
||||
|
||||
test("7. empty phases array yields no refs", () => {
|
||||
expect(
|
||||
collectCasRefs(plannerMetaSchema, {
|
||||
status: "planned",
|
||||
phases: [],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-runtime",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* greet workflow — smoke test entry
|
||||
* Single role: greeter takes a prompt and returns a structured greeting.
|
||||
* 小橘 🍊
|
||||
*/
|
||||
|
||||
import type {
|
||||
AdapterFn,
|
||||
ModeratorTable,
|
||||
RoleFn,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
WorkflowDefinition,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import { createWorkflow, END, START } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
type GreetMeta = {
|
||||
greeter: { greeting: string; language: string };
|
||||
};
|
||||
|
||||
const greeterSchema = z.object({
|
||||
greeting: z.string().describe("A friendly greeting message"),
|
||||
language: z.string().describe("The language of the greeting"),
|
||||
});
|
||||
|
||||
const roles: WorkflowDefinition<GreetMeta>["roles"] = {
|
||||
greeter: {
|
||||
description: "Generates a friendly greeting",
|
||||
systemPrompt:
|
||||
"You are a friendly greeter. Given a user prompt, produce a warm greeting. Respond in valid JSON with keys: greeting (string), language (string).",
|
||||
schema: greeterSchema,
|
||||
},
|
||||
};
|
||||
|
||||
const table: ModeratorTable<GreetMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "greeter" }],
|
||||
greeter: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
|
||||
export const descriptor = {
|
||||
name: "greet",
|
||||
description: "A simple greeting workflow for smoke testing",
|
||||
graph: { [START]: ["greeter"], greeter: [END] },
|
||||
roles: { greeter: { description: "Generates a friendly greeting" } },
|
||||
};
|
||||
|
||||
function createLazyAdapter(): AdapterFn {
|
||||
let cached: { baseUrl: string; apiKey: string; model: string } | null = null;
|
||||
function getProvider() {
|
||||
if (cached !== null) return cached;
|
||||
const apiKey = process.env.DASHSCOPE_API_KEY;
|
||||
if (!apiKey) throw new Error("missing env: DASHSCOPE_API_KEY");
|
||||
cached = {
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey,
|
||||
model: process.env.WORKFLOW_MODEL ?? "qwen-plus",
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
return (<T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
|
||||
return async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const provider = getProvider();
|
||||
const response = await fetch(`${provider.baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: provider.model,
|
||||
messages: [
|
||||
{ role: "system", content: prompt },
|
||||
{
|
||||
role: "user",
|
||||
content: `${ctx.start.content}\n\nRespond with JSON: ${JSON.stringify(z.toJSONSchema(schema))}`,
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`LLM error ${response.status}: ${body.slice(0, 500)}`);
|
||||
}
|
||||
const data = (await response.json()) as { choices: Array<{ message: { content: string } }> };
|
||||
const text = data.choices[0]?.message?.content;
|
||||
if (!text) throw new Error("Empty LLM response");
|
||||
const parsed = schema.parse(JSON.parse(text));
|
||||
return { meta: parsed, childThread: null };
|
||||
};
|
||||
}) as AdapterFn;
|
||||
}
|
||||
|
||||
export const run = createWorkflow<GreetMeta>(
|
||||
{ roles, table },
|
||||
{ adapter: createLazyAdapter(), overrides: null },
|
||||
);
|
||||
@@ -0,0 +1,122 @@
|
||||
import * as z from "zod/v4";
|
||||
|
||||
type ZodSchema = z.ZodType;
|
||||
|
||||
type DefPipeIn = { in: ZodSchema };
|
||||
|
||||
function hasCasRef(schema: ZodSchema): boolean {
|
||||
const meta = z.globalRegistry.get(schema);
|
||||
return meta !== undefined && meta.casRef === true;
|
||||
}
|
||||
|
||||
function walkOptional(schema: z.ZodOptional<ZodSchema>, data: unknown): string[] {
|
||||
if (data === undefined) {
|
||||
return [];
|
||||
}
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkNullable(schema: z.ZodNullable<ZodSchema>, data: unknown): string[] {
|
||||
if (data === null) {
|
||||
return [];
|
||||
}
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkDefault(schema: z.ZodDefault<ZodSchema>, data: unknown): string[] {
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkPrefault(schema: z.ZodPrefault<ZodSchema>, data: unknown): string[] {
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkCatch(schema: z.ZodCatch<ZodSchema>, data: unknown): string[] {
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkReadonly(schema: z.ZodReadonly<ZodSchema>, data: unknown): string[] {
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkPipe(def: DefPipeIn, data: unknown): string[] {
|
||||
return walkCasRefs(def.in, data);
|
||||
}
|
||||
|
||||
function walkString(schema: ZodSchema, data: unknown): string[] {
|
||||
if (hasCasRef(schema) && typeof data === "string") {
|
||||
return [data];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function walkObject(schema: z.ZodObject<z.ZodRawShape>, data: unknown): string[] {
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
||||
return [];
|
||||
}
|
||||
const record = data as Record<string, unknown>;
|
||||
const shape = schema.shape;
|
||||
const refs: string[] = [];
|
||||
for (const [key, fieldSchema] of Object.entries(shape)) {
|
||||
refs.push(...walkCasRefs(fieldSchema as ZodSchema, record[key]));
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function walkArray(schema: z.ZodArray<ZodSchema>, data: unknown): string[] {
|
||||
if (!Array.isArray(data)) {
|
||||
return [];
|
||||
}
|
||||
const element = schema.element;
|
||||
const refs: string[] = [];
|
||||
for (const item of data) {
|
||||
refs.push(...walkCasRefs(element, item));
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function walkUnion(schema: z.ZodUnion<readonly ZodSchema[]>, data: unknown): string[] {
|
||||
for (const option of schema.options) {
|
||||
const parsed = option.safeParse(data);
|
||||
if (parsed.success) {
|
||||
return walkCasRefs(option, data);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function walkCasRefs(schema: ZodSchema, data: unknown): string[] {
|
||||
const def = schema.def;
|
||||
|
||||
switch (def.type) {
|
||||
case "optional":
|
||||
return walkOptional(schema as z.ZodOptional<ZodSchema>, data);
|
||||
case "nullable":
|
||||
return walkNullable(schema as z.ZodNullable<ZodSchema>, data);
|
||||
case "default":
|
||||
return walkDefault(schema as z.ZodDefault<ZodSchema>, data);
|
||||
case "prefault":
|
||||
return walkPrefault(schema as z.ZodPrefault<ZodSchema>, data);
|
||||
case "catch":
|
||||
return walkCatch(schema as z.ZodCatch<ZodSchema>, data);
|
||||
case "readonly":
|
||||
return walkReadonly(schema as z.ZodReadonly<ZodSchema>, data);
|
||||
case "pipe":
|
||||
return walkPipe(def as unknown as DefPipeIn, data);
|
||||
case "string":
|
||||
return walkString(schema, data);
|
||||
case "object":
|
||||
return walkObject(schema as z.ZodObject<z.ZodRawShape>, data);
|
||||
case "array":
|
||||
return walkArray(schema as z.ZodArray<ZodSchema>, data);
|
||||
case "union":
|
||||
return walkUnion(schema as z.ZodUnion<readonly ZodSchema[]>, data);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect CAS content hashes from meta using `casRef` annotations on the Zod schema. */
|
||||
export function collectCasRefs(schema: ZodSchema, data: unknown): string[] {
|
||||
return walkCasRefs(schema, data);
|
||||
}
|
||||
@@ -2,13 +2,13 @@ import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import { collectCasRefs } from "./collect-cas-refs.js";
|
||||
import {
|
||||
type AdapterBinding,
|
||||
type AdapterFn,
|
||||
type AdvanceOutcome,
|
||||
END,
|
||||
type ModeratorContext,
|
||||
type RoleDefinition,
|
||||
type RoleMeta,
|
||||
type RoleOutput,
|
||||
type RoleStep,
|
||||
@@ -26,17 +26,6 @@ function isRoleNext<M extends RoleMeta>(
|
||||
return next !== END;
|
||||
}
|
||||
|
||||
function resolveExtractedRefs(
|
||||
roleDef: RoleDefinition<Record<string, unknown>>,
|
||||
meta: unknown,
|
||||
): string[] {
|
||||
const extractRefsFn = roleDef.extractRefs;
|
||||
if (extractRefsFn === null || typeof extractRefsFn !== "function") {
|
||||
return [];
|
||||
}
|
||||
return extractRefsFn(meta as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function _mergeUniqueHashes(a: readonly string[], b: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
@@ -90,10 +79,7 @@ async function advanceOneRound<M extends RoleMeta>(
|
||||
const result = await roleFn(modCtx as unknown as ThreadContext, runtime);
|
||||
const meta = result.meta;
|
||||
|
||||
const refsFromMeta = resolveExtractedRefs(
|
||||
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
|
||||
meta,
|
||||
);
|
||||
const refsFromMeta = collectCasRefs(roleDef.schema as z.ZodType, meta);
|
||||
|
||||
const contentPayload = JSON.stringify(meta);
|
||||
const contentHash = await putContentNodeWithRefs(runtime.cas, contentPayload, refsFromMeta);
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
export { buildThreadContext } from "./build-context.js";
|
||||
export { createWorkflow } from "./create-workflow.js";
|
||||
export { err, ok } from "./result.js";
|
||||
export type {
|
||||
AdapterBinding,
|
||||
AdapterFn,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
@@ -13,7 +10,6 @@ export type {
|
||||
ModeratorCondition,
|
||||
ModeratorContext,
|
||||
ModeratorTable,
|
||||
ModeratorTransition,
|
||||
Result,
|
||||
RoleDefinition,
|
||||
RoleFn,
|
||||
@@ -21,17 +17,15 @@ export type {
|
||||
RoleOutput,
|
||||
RoleResult,
|
||||
RoleStep,
|
||||
StartStep,
|
||||
ThreadContext,
|
||||
WorkflowCompletion,
|
||||
WorkflowDefinition,
|
||||
WorkflowDescriptor,
|
||||
WorkflowFn,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowResult,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
WorkflowRuntime,
|
||||
} from "./types.js";
|
||||
export { END, START } from "./types.js";
|
||||
} from "@uncaged/workflow-protocol";
|
||||
export { END, START } from "@uncaged/workflow-protocol";
|
||||
export { buildThreadContext } from "./build-context.js";
|
||||
export { collectCasRefs } from "./collect-cas-refs.js";
|
||||
export { createWorkflow } from "./create-workflow.js";
|
||||
export { err, ok } from "./result.js";
|
||||
|
||||
@@ -7,6 +7,7 @@ export type {
|
||||
AdapterFn,
|
||||
AdvanceOutcome,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# @uncaged/workflow-template-develop
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.4
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.3
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.2
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.1
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-register@0.5.0-alpha.0
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
/**
|
||||
* develop bundle entry — 小橘 🍊
|
||||
*
|
||||
* All roles use cursor-agent with workspace auto-extracted from context.
|
||||
* planner/coder/reviewer → cursor-agent (needs code editing)
|
||||
* tester/committer → hermes-agent (lightweight, no editing needed)
|
||||
*/
|
||||
import { createCursorAgent } from "@uncaged/workflow-agent-cursor";
|
||||
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
|
||||
import { createWorkflow } from "@uncaged/workflow-runtime";
|
||||
import { optionalEnv, requireEnv } from "@uncaged/workflow-util";
|
||||
import { env } from "@uncaged/workflow-util";
|
||||
import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js";
|
||||
|
||||
const llmProvider = {
|
||||
baseUrl: optionalEnv(
|
||||
"WORKFLOW_LLM_BASE_URL",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
),
|
||||
apiKey: requireEnv("WORKFLOW_LLM_API_KEY", "set WORKFLOW_LLM_API_KEY for meta extraction"),
|
||||
model: optionalEnv("WORKFLOW_LLM_MODEL", "qwen-plus"),
|
||||
};
|
||||
|
||||
const adapter = createCursorAgent({
|
||||
command: requireEnv("WORKFLOW_CURSOR_COMMAND", "set WORKFLOW_CURSOR_COMMAND (e.g. cursor-agent)"),
|
||||
model: optionalEnv("WORKFLOW_CURSOR_MODEL"),
|
||||
timeout: optionalEnv("WORKFLOW_CURSOR_TIMEOUT")
|
||||
? Number(optionalEnv("WORKFLOW_CURSOR_TIMEOUT"))
|
||||
: 0,
|
||||
const cursorAdapter = createCursorAgent({
|
||||
command: env("WORKFLOW_CURSOR_COMMAND", "/home/azureuser/.local/bin/cursor-agent"),
|
||||
model: env("WORKFLOW_CURSOR_MODEL", "auto"),
|
||||
timeout: Number(env("WORKFLOW_CURSOR_TIMEOUT", "0")),
|
||||
workspace: null,
|
||||
llmProvider,
|
||||
});
|
||||
|
||||
const wf = createWorkflow(developWorkflowDefinition, { adapter, overrides: null });
|
||||
const hermesAdapter = createHermesAgent({
|
||||
command: env("WORKFLOW_HERMES_COMMAND", "/home/azureuser/.local/bin/hermes"),
|
||||
model: env("WORKFLOW_HERMES_MODEL", "") || null,
|
||||
timeout: Number(env("WORKFLOW_HERMES_TIMEOUT", "0")) || null,
|
||||
});
|
||||
|
||||
const wf = createWorkflow(developWorkflowDefinition, {
|
||||
adapter: cursorAdapter,
|
||||
overrides: {
|
||||
tester: hermesAdapter,
|
||||
committer: hermesAdapter,
|
||||
},
|
||||
});
|
||||
|
||||
export const descriptor = buildDevelopDescriptor();
|
||||
export const run = wf;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-template-develop",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as z from "zod/v4";
|
||||
export const coderMetaSchema = z.object({
|
||||
completedPhase: z
|
||||
.string()
|
||||
.meta({ casRef: true })
|
||||
.describe(
|
||||
"The planner phase hash finished this round. If multiple phases were completed, use the last finished phase hash.",
|
||||
),
|
||||
@@ -36,5 +37,4 @@ export const coderRole: RoleDefinition<CoderMeta> = {
|
||||
"Implements the next incomplete planner phase and reports structured completion metadata.",
|
||||
systemPrompt: CODER_SYSTEM,
|
||||
schema: coderMetaSchema,
|
||||
extractRefs: (meta) => [meta.completedPhase],
|
||||
};
|
||||
|
||||
@@ -29,5 +29,4 @@ export const committerRole: RoleDefinition<CommitterMeta> = {
|
||||
description: "Creates a branch and commits changes.",
|
||||
systemPrompt: COMMITTER_SYSTEM,
|
||||
schema: committerMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const phaseSchema = z.object({
|
||||
hash: z.string(),
|
||||
hash: z.string().meta({ casRef: true }),
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
@@ -63,5 +63,4 @@ export const plannerRole: RoleDefinition<PlannerMeta> = {
|
||||
description: "Breaks the task into sequential phases for the coder.",
|
||||
systemPrompt: PLANNER_SYSTEM,
|
||||
schema: plannerMetaSchema,
|
||||
extractRefs: (meta) => meta.status === "planned" ? meta.phases.map((p) => p.hash) : [],
|
||||
};
|
||||
|
||||
@@ -42,5 +42,4 @@ export const reviewerRole: RoleDefinition<ReviewerMeta> = {
|
||||
description: "Runs git diff checks and sets approved when the change is ready.",
|
||||
systemPrompt: REVIEWER_SYSTEM,
|
||||
schema: reviewerMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
|
||||
@@ -24,5 +24,4 @@ export const testerRole: RoleDefinition<TesterMeta> = {
|
||||
description: "Runs test, build, and lint commands and reports pass or fail with details.",
|
||||
systemPrompt: TESTER_SYSTEM,
|
||||
schema: testerMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user