Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||
|
||||
+7
-1
@@ -1,7 +1,13 @@
|
||||
{
|
||||
"$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": {
|
||||
|
||||
@@ -9,6 +9,7 @@ const agent = createCursorAgent({
|
||||
command: "/home/azureuser/.local/bin/cursor-agent",
|
||||
model: "auto",
|
||||
timeout: 300_000,
|
||||
workspace: null,
|
||||
});
|
||||
|
||||
export const descriptor = buildDevelopDescriptor();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -180,6 +180,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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -301,13 +301,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,21 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||
|
||||
const baseConfig = {
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null as string | null,
|
||||
timeout: 0,
|
||||
workspace: null as string | null,
|
||||
};
|
||||
|
||||
describe("validateCursorAgentConfig", () => {
|
||||
test("accepts valid config", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
...baseConfig,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects non-absolute command", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
...baseConfig,
|
||||
command: "cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
@@ -25,28 +29,35 @@ describe("validateCursorAgentConfig", () => {
|
||||
|
||||
test("rejects negative timeout", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCursorAgent", () => {
|
||||
test("returns an AdapterFn", () => {
|
||||
const agent = createCursorAgent({
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
...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,
|
||||
});
|
||||
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",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { WorkflowRuntime } 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,25 +33,15 @@ function resolveCursorModel(model: string | null): string {
|
||||
return model === null ? "auto" : model;
|
||||
}
|
||||
|
||||
/** Runs `cursor-agent` with workspace extracted from thread context via runtime.extract. */
|
||||
export function createCursorAgent(config: CursorAgentConfig) {
|
||||
const modelFlag = resolveCursorModel(config.model);
|
||||
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
||||
const logger = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
return createTextAdapter(async (ctx, prompt, runtime: WorkflowRuntime) => {
|
||||
const validated = validateCursorAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
const 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.",
|
||||
);
|
||||
}
|
||||
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}`;
|
||||
@@ -75,5 +65,33 @@ export function createCursorAgent(config: CursorAgentConfig) {
|
||||
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 };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,4 +3,9 @@ export type CursorAgentConfig = {
|
||||
command: string;
|
||||
model: string | null;
|
||||
timeout: number;
|
||||
/**
|
||||
* When non-null, use this workspace directory for `cursor-agent` instead of resolving it
|
||||
* from the thread via runtime extraction.
|
||||
*/
|
||||
workspace: string | null;
|
||||
};
|
||||
|
||||
@@ -11,5 +11,8 @@ export function validateCursorAgentConfig(config: CursorAgentConfig): Result<voi
|
||||
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, _runtime) => {
|
||||
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, _runtime) => {
|
||||
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",
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -96,42 +96,45 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||
// Track which occurrence to jump to next per role (cycling)
|
||||
const clickCycleRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
const handleGraphNodeClick = useCallback((nodeId: string) => {
|
||||
// Only allow clicks on lit (non-default) nodes
|
||||
if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
|
||||
const handleGraphNodeClick = useCallback(
|
||||
(nodeId: string) => {
|
||||
// Only allow clicks on lit (non-default) nodes
|
||||
if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
|
||||
|
||||
// __start__: scroll to the first record (thread-start prompt)
|
||||
if (nodeId === "__start__") {
|
||||
const firstCard = document.querySelector('[data-record-index="0"]');
|
||||
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return;
|
||||
}
|
||||
// __start__: scroll to the first record (thread-start prompt)
|
||||
if (nodeId === "__start__") {
|
||||
const firstCard = document.querySelector('[data-record-index="0"]');
|
||||
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return;
|
||||
}
|
||||
|
||||
// __end__: scroll to bottom
|
||||
if (nodeId === "__end__") {
|
||||
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
return;
|
||||
}
|
||||
// __end__: scroll to bottom
|
||||
if (nodeId === "__end__") {
|
||||
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Role nodes: cycle through occurrences
|
||||
const indices = indicesByRole.get(nodeId);
|
||||
if (indices === undefined || indices.length === 0) return;
|
||||
// Role nodes: cycle through occurrences
|
||||
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 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) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||
setHighlightedRole(nodeId);
|
||||
highlightTimerRef.current = setTimeout(() => {
|
||||
setHighlightedRole(null);
|
||||
highlightTimerRef.current = null;
|
||||
}, 1500);
|
||||
}
|
||||
}, [nodeStates, indicesByRole]);
|
||||
const el = document.querySelector(`[data-record-index="${idx}"]`);
|
||||
if (el !== null) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||
setHighlightedRole(nodeId);
|
||||
highlightTimerRef.current = setTimeout(() => {
|
||||
setHighlightedRole(null);
|
||||
highlightTimerRef.current = null;
|
||||
}, 1500);
|
||||
}
|
||||
},
|
||||
[nodeStates, indicesByRole],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -285,7 +288,11 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div key={key} data-record-index={i}><RecordCard record={r} highlighted={false} /></div>;
|
||||
return (
|
||||
<div key={key} data-record-index={i}>
|
||||
<RecordCard record={r} highlighted={false} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={recordsEndRef} aria-hidden />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
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 flattenSchema(
|
||||
schema: Record<string, unknown>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
): SchemaRow[] {
|
||||
const rows: SchemaRow[] = [];
|
||||
|
||||
// Handle oneOf / discriminatedUnion
|
||||
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(oneOf) && oneOf.length > 0) {
|
||||
for (let vi = 0; vi < oneOf.length; vi++) {
|
||||
const variant = oneOf[vi];
|
||||
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
let variantLabel = `Variant ${vi + 1}`;
|
||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||
if (pDef.const !== undefined) {
|
||||
variantLabel = `${pName}: ${String(pDef.const)}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const isLast = vi === oneOf.length - 1;
|
||||
const connector = isLast ? "└" : "├";
|
||||
rows.push({
|
||||
key: `${keyPrefix}variant-${vi}`,
|
||||
name: `${parentPrefix}${connector} ${variantLabel}`,
|
||||
type: "",
|
||||
description: "",
|
||||
depth,
|
||||
prefix: parentPrefix,
|
||||
isVariantHeader: true,
|
||||
});
|
||||
const childPrefix = `${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;
|
||||
const subRows = flattenProperty(
|
||||
pName,
|
||||
pDef,
|
||||
depth + 1,
|
||||
childPrefix,
|
||||
`${keyPrefix}v${vi}-`,
|
||||
variantRequired,
|
||||
);
|
||||
rows.push(...subRows);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const required = new Set<string>(
|
||||
Array.isArray(schema.required) ? (schema.required as string[]) : [],
|
||||
);
|
||||
for (const [name, prop] of Object.entries(props)) {
|
||||
const subRows = flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required);
|
||||
rows.push(...subRows);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function flattenProperty(
|
||||
name: string,
|
||||
prop: Record<string, unknown>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
required: Set<string>,
|
||||
): SchemaRow[] {
|
||||
const rows: 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 description = String(prop.description ?? "");
|
||||
const displayName = depth > 0 ? `${parentPrefix}└─ ${name}` : name;
|
||||
|
||||
rows.push({
|
||||
key: `${keyPrefix}${name}`,
|
||||
name: displayName,
|
||||
type,
|
||||
description,
|
||||
depth,
|
||||
prefix: parentPrefix,
|
||||
isVariantHeader: false,
|
||||
});
|
||||
|
||||
if (prop.type === "object" && prop.properties !== undefined) {
|
||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||
rows.push(
|
||||
...flattenSchema(
|
||||
prop as Record<string, unknown>,
|
||||
depth + 1,
|
||||
childPrefix,
|
||||
`${keyPrefix}${name}-`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (prop.type === "array") {
|
||||
const items = prop.items as Record<string, unknown> | undefined;
|
||||
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
|
||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||
rows.push(...flattenSchema(items, depth + 1, childPrefix, `${keyPrefix}${name}-`));
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOneOf) {
|
||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||
rows.push(
|
||||
...flattenSchema(
|
||||
prop as Record<string, unknown>,
|
||||
depth + 1,
|
||||
childPrefix,
|
||||
`${keyPrefix}${name}-`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,17 @@ const FEEDBACK_OFFSET_X = 80;
|
||||
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
|
||||
|
||||
@@ -45,11 +45,41 @@ export function RoleNode(props: NodeProps) {
|
||||
}}
|
||||
title={data.description}
|
||||
>
|
||||
<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} />
|
||||
<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
|
||||
@@ -67,7 +97,13 @@ export function RoleNode(props: NodeProps) {
|
||||
{data.description}
|
||||
</div>
|
||||
)}
|
||||
<Handle type="source" position={Position.Bottom} id="bottom-out" style={handleStyle} isConnectable={false} />
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="bottom-out"
|
||||
style={handleStyle}
|
||||
isConnectable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,29 @@ export function TerminalNode(props: NodeProps) {
|
||||
isConnectable={false}
|
||||
/>
|
||||
) : (
|
||||
<Handle type="target" position={Position.Top} id="top-in" 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>
|
||||
|
||||
@@ -36,13 +36,19 @@ function edgeKey(e: WorkflowGraphEdge): string {
|
||||
return `${e.from}->${e.to}::${e.condition}`;
|
||||
}
|
||||
|
||||
// ── Strategy 1: Longest-path layering (Sugiyama step 1) ─────────────
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: topological sort is inherently branchy
|
||||
function extractSpine(edges: readonly WorkflowGraphEdge[]): string[] {
|
||||
function computeLayersLongestPath(edges: readonly WorkflowGraphEdge[]): string[][] {
|
||||
// Collect all node IDs
|
||||
const ids = new Set<string>();
|
||||
for (const e of edges) {
|
||||
@@ -50,62 +56,120 @@ function extractSpine(edges: readonly WorkflowGraphEdge[]): string[] {
|
||||
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)
|
||||
// Build adjacency (excluding self-loops)
|
||||
const adj = new Map<string, string[]>();
|
||||
const inEdges = new Map<string, string[]>();
|
||||
for (const id of ids) {
|
||||
if (!visited.has(id)) {
|
||||
spine.push(id);
|
||||
adj.set(id, []);
|
||||
inEdges.set(id, []);
|
||||
}
|
||||
// Detect back-edges via DFS to break cycles
|
||||
const backEdges = new Set<string>();
|
||||
{
|
||||
const WHITE = 0;
|
||||
const GRAY = 1;
|
||||
const BLACK = 2;
|
||||
const color = new Map<string, number>();
|
||||
for (const id of ids) color.set(id, WHITE);
|
||||
|
||||
// Temporary full adjacency for cycle detection
|
||||
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) {
|
||||
// Back-edge: u -> v where v is an ancestor
|
||||
backEdges.add(`${u}->${v}`);
|
||||
} else if (c === WHITE) {
|
||||
dfs(v);
|
||||
}
|
||||
}
|
||||
color.set(u, BLACK);
|
||||
}
|
||||
|
||||
// Start DFS from __start__ first for determinism
|
||||
if (ids.has(START_ID)) dfs(START_ID);
|
||||
for (const id of ids) {
|
||||
if ((color.get(id) ?? WHITE) === WHITE) dfs(id);
|
||||
}
|
||||
}
|
||||
|
||||
return spine;
|
||||
// Build DAG adjacency (without back-edges)
|
||||
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);
|
||||
inEdges.get(e.to)?.push(e.from);
|
||||
}
|
||||
|
||||
// Longest-path ranking via topological order (Kahn's algorithm)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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()!;
|
||||
const curRank = rank.get(cur) ?? 0;
|
||||
for (const next of adj.get(cur) ?? []) {
|
||||
// Longest path: take max
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group by rank
|
||||
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);
|
||||
}
|
||||
|
||||
// Sort within layers alphabetically for stability, but __start__ first, __end__ last
|
||||
for (const layer of layers) {
|
||||
layer.sort((a, b) => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// Remove empty layers
|
||||
return layers.filter((l) => l.length > 0);
|
||||
}
|
||||
|
||||
// ── Shared helpers ──────────────────────────────────────────────────
|
||||
|
||||
function buildRoleNode(
|
||||
id: string,
|
||||
pos: { x: number; y: number },
|
||||
@@ -137,50 +201,78 @@ function buildTerminalNode(
|
||||
};
|
||||
}
|
||||
|
||||
function computeLayout(input: LayoutInput): LayoutResult {
|
||||
const spine = extractSpine(input.edges);
|
||||
// ── Longest-path layout (uses same edge-building as before) ─────────
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: layout logic is inherently branchy
|
||||
function computeLayoutLongestPath(input: LayoutInput): LayoutResult {
|
||||
const layers = computeLayersLongestPath(input.edges);
|
||||
|
||||
// Flatten layers into a rank map (layer index = rank)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Position nodes along a vertical spine, centered horizontally
|
||||
const centerX = ROLE_NODE_WIDTH / 2; // left edge at x=0, center at width/2
|
||||
// Horizontal gap between nodes in the same layer
|
||||
const H_GAP = 40;
|
||||
|
||||
// Position nodes: each layer is a horizontal row
|
||||
const nodePositions = new Map<string, { x: number; y: number; w: number; h: number }>();
|
||||
|
||||
// Find max layer width for centering
|
||||
const layerWidths: number[] = [];
|
||||
for (const layer of layers) {
|
||||
let w = 0;
|
||||
for (const id of layer) {
|
||||
w += nodeSize(id).width;
|
||||
}
|
||||
w += (layer.length - 1) * H_GAP;
|
||||
layerWidths.push(w);
|
||||
}
|
||||
const maxLayerWidth = Math.max(...layerWidths, ROLE_NODE_WIDTH);
|
||||
const centerX = maxLayerWidth / 2;
|
||||
|
||||
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;
|
||||
for (let li = 0; li < layers.length; li++) {
|
||||
const layer = layers[li];
|
||||
const totalWidth = layerWidths[li];
|
||||
let x = centerX - totalWidth / 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 + H_GAP;
|
||||
if (size.height > maxH) maxH = size.height;
|
||||
}
|
||||
y += maxH + 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));
|
||||
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";
|
||||
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 routedCountByTarget = 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 isSkipForward = !isSelfLoop && !isFeedback && targetRank - sourceRank > 1;
|
||||
|
||||
const sourcePos = nodePositions.get(e.from);
|
||||
const targetPos = nodePositions.get(e.to);
|
||||
@@ -190,10 +282,9 @@ function computeLayout(input: LayoutInput): LayoutResult {
|
||||
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);
|
||||
if (isFeedback || isSkipForward) {
|
||||
const count = routedCountByTarget.get(e.to) ?? 0;
|
||||
routedCountByTarget.set(e.to, count + 1);
|
||||
feedbackSide = count % 2 === 0 ? "right" : "left";
|
||||
const offsetX =
|
||||
feedbackSide === "right"
|
||||
@@ -203,27 +294,31 @@ function computeLayout(input: LayoutInput): LayoutResult {
|
||||
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,
|
||||
sourceHandle: isFeedback ? (feedbackSide === "left" ? "left-out" : "right-out") : "bottom-out",
|
||||
targetHandle: isFeedback ? (feedbackSide === "left" ? "left-in" : "right-in") : "top-in",
|
||||
sourceHandle:
|
||||
isFeedback || isSkipForward
|
||||
? feedbackSide === "left"
|
||||
? "left-out"
|
||||
: "right-out"
|
||||
: "bottom-out",
|
||||
targetHandle:
|
||||
isFeedback || isSkipForward ? (feedbackSide === "left" ? "left-in" : "right-in") : "top-in",
|
||||
type: "condition",
|
||||
data: {
|
||||
condition: e.condition,
|
||||
conditionDescription: e.conditionDescription,
|
||||
isFallback,
|
||||
isFeedback,
|
||||
isFeedback: isFeedback || isSkipForward,
|
||||
isSelfLoop,
|
||||
feedbackSide,
|
||||
labelX,
|
||||
@@ -235,6 +330,8 @@ function computeLayout(input: LayoutInput): LayoutResult {
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// ── Public hook ─────────────────────────────────────────────────────
|
||||
|
||||
export function useLayout(input: LayoutInput): LayoutResult {
|
||||
return useMemo(() => computeLayout(input), [input]);
|
||||
return useMemo(() => computeLayoutLongestPath(input), [input]);
|
||||
}
|
||||
|
||||
@@ -1,175 +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>;
|
||||
@@ -184,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",
|
||||
|
||||
@@ -4,7 +4,6 @@ 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 type { RoleOutput, WorkflowFn } from "@uncaged/workflow-runtime";
|
||||
@@ -365,7 +364,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>;
|
||||
|
||||
@@ -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,15 @@ 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.
|
||||
* Agents with no extra options use `AgentFn` (Opt defaults to void).
|
||||
*/
|
||||
export type AgentFn<Opt = void> = Opt extends void
|
||||
? (ctx: ThreadContext) => Promise<string>
|
||||
: (ctx: ThreadContext, options: Opt) => Promise<string>;
|
||||
|
||||
export type AdapterBinding = {
|
||||
adapter: AdapterFn;
|
||||
overrides: Partial<Record<string, AdapterFn>> | null;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,11 +1,9 @@
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -88,8 +88,10 @@ export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescr
|
||||
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 : "";
|
||||
roles[roleName] = {
|
||||
description: roleDesc,
|
||||
systemPrompt,
|
||||
schema: schema as WorkflowRoleSchema,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export type {
|
||||
ExtractBundleExportsOptions,
|
||||
ExtractedBundleExports,
|
||||
WorkflowBundleValidationInput,
|
||||
WorkflowDescriptor,
|
||||
@@ -10,7 +9,6 @@ export type {
|
||||
} from "./bundle/index.js";
|
||||
export {
|
||||
buildDescriptor,
|
||||
ensureUncagedWorkflowSymlink,
|
||||
extractBundleExports,
|
||||
importWorkflowBundleModule,
|
||||
stringifyWorkflowDescriptor,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-runtime",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
|
||||
@@ -5,6 +5,7 @@ export type {
|
||||
AdapterBinding,
|
||||
AdapterFn,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
CasStore,
|
||||
ExtractFn,
|
||||
ExtractResult,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# @uncaged/workflow-template-solve-issue
|
||||
|
||||
## 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
|
||||
|
||||
@@ -7,14 +7,13 @@
|
||||
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
|
||||
import { workflowAdapter } from "@uncaged/workflow-execute";
|
||||
import { createWorkflow } from "@uncaged/workflow-runtime";
|
||||
import { optionalEnv } from "@uncaged/workflow-util";
|
||||
import { env } from "@uncaged/workflow-util";
|
||||
import { buildSolveIssueDescriptor, solveIssueWorkflowDefinition } from "./src/index.js";
|
||||
|
||||
const adapter = createHermesAgent({
|
||||
model: optionalEnv("WORKFLOW_HERMES_MODEL"),
|
||||
timeout: optionalEnv("WORKFLOW_HERMES_TIMEOUT")
|
||||
? Number(optionalEnv("WORKFLOW_HERMES_TIMEOUT"))
|
||||
: null,
|
||||
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(solveIssueWorkflowDefinition, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-template-solve-issue",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# @uncaged/workflow-util-agent
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.4
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.3
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.2
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fix: include create-agent-adapter.ts in published src
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.1
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.0
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-util-agent",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import type {
|
||||
AdapterFn,
|
||||
AgentFn,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
export type ExtractOptionsFn<Opt> = (
|
||||
ctx: ThreadContext,
|
||||
prompt: string,
|
||||
runtime: WorkflowRuntime,
|
||||
) => Promise<Opt>;
|
||||
|
||||
/**
|
||||
* Bridges {@link AgentFn} to {@link AdapterFn}.
|
||||
*
|
||||
* 1. extract(ctx, prompt, runtime) → Opt
|
||||
* 2. agent(ctx, options) → raw string
|
||||
* 3. Store raw string in CAS
|
||||
* 4. runtime.extract(schema, contentHash) → typed meta T
|
||||
*/
|
||||
export function createAgentAdapter<Opt>(
|
||||
agent: AgentFn<Opt>,
|
||||
extract: ExtractOptionsFn<Opt>,
|
||||
): AdapterFn {
|
||||
return <T>(prompt: string, schema: z.ZodType<T>) => {
|
||||
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const options = await extract(ctx, prompt, runtime);
|
||||
const raw = await (agent as (ctx: ThreadContext, optionsParam: Opt) => Promise<string>)(
|
||||
ctx,
|
||||
options,
|
||||
);
|
||||
const contentHash = await putContentNodeWithRefs(runtime.cas, raw, []);
|
||||
const extracted = await runtime.extract(
|
||||
schema as z.ZodType<Record<string, unknown>>,
|
||||
contentHash,
|
||||
);
|
||||
return { meta: extracted.meta as T, childThread: null };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function createSimpleAgentAdapter(agent: AgentFn<void>): AdapterFn {
|
||||
return createAgentAdapter(agent, async () => undefined as unknown as undefined);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import type {
|
||||
AdapterFn,
|
||||
RoleResult,
|
||||
ThreadContext,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
export type { WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
|
||||
/**
|
||||
* Result from a text-producing agent (CLI spawn, LLM call, etc.).
|
||||
* `output` is the raw text; `childThread` links to a spawned sub-workflow.
|
||||
*/
|
||||
export type TextAdapterResult = {
|
||||
output: string;
|
||||
childThread: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A function that produces raw text output given the thread context and
|
||||
* the system prompt for the current role.
|
||||
*/
|
||||
export type TextProducerFn = (
|
||||
ctx: ThreadContext,
|
||||
prompt: string,
|
||||
runtime: WorkflowRuntime,
|
||||
) => Promise<string | TextAdapterResult>;
|
||||
|
||||
/**
|
||||
* Creates an {@link AdapterFn} from a text-producing function.
|
||||
*
|
||||
* The adapter:
|
||||
* 1. Calls the producer with thread context + system prompt
|
||||
* 2. Stores output in CAS
|
||||
* 3. Runs the extract phase to produce typed meta
|
||||
* 4. Returns `{ meta, childThread }`
|
||||
*/
|
||||
export function createTextAdapter(producer: TextProducerFn): AdapterFn {
|
||||
return <T>(prompt: string, schema: z.ZodType<T>) => {
|
||||
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const result = await producer(ctx, prompt, runtime);
|
||||
const output = typeof result === "string" ? result : result.output;
|
||||
const childThread = typeof result === "string" ? null : result.childThread;
|
||||
const contentHash = await putContentNodeWithRefs(runtime.cas, output, []);
|
||||
const extracted = await runtime.extract(
|
||||
schema as z.ZodType<Record<string, unknown>>,
|
||||
contentHash,
|
||||
);
|
||||
return { meta: extracted.meta as T, childThread };
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export { buildAgentPrompt, buildThreadInput } from "./build-agent-prompt.js";
|
||||
export type { TextAdapterResult, TextProducerFn } from "./create-text-adapter.js";
|
||||
export { createTextAdapter } from "./create-text-adapter.js";
|
||||
export type { ExtractOptionsFn } from "./create-agent-adapter.js";
|
||||
export { createAgentAdapter, createSimpleAgentAdapter } from "./create-agent-adapter.js";
|
||||
export type { SpawnCliConfig, SpawnCliError, SpawnCliResult } from "./spawn-cli.js";
|
||||
export { spawnCli } from "./spawn-cli.js";
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# @uncaged/workflow-util
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Replace optionalEnv/requireEnv with unified env(name, fallback) API
|
||||
- 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,44 +1,20 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { optionalEnv, requireEnv } from "../src/env.js";
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { env } from "../src/env.js";
|
||||
|
||||
describe("requireEnv", () => {
|
||||
test("returns value when set", () => {
|
||||
process.env.TEST_REQ = "hello";
|
||||
expect(requireEnv("TEST_REQ", "missing")).toBe("hello");
|
||||
delete process.env.TEST_REQ;
|
||||
describe("env", () => {
|
||||
it("returns env value when set", () => {
|
||||
process.env.TEST_ENV_SET = "hello";
|
||||
expect(env("TEST_ENV_SET", "default")).toBe("hello");
|
||||
delete process.env.TEST_ENV_SET;
|
||||
});
|
||||
|
||||
test("throws with message when missing", () => {
|
||||
expect(() => requireEnv("TEST_MISSING_XYZ", "need this")).toThrow("need this");
|
||||
it("returns fallback when missing", () => {
|
||||
expect(env("TEST_ENV_MISSING_XYZ", "fallback")).toBe("fallback");
|
||||
});
|
||||
|
||||
test("throws when empty string", () => {
|
||||
process.env.TEST_EMPTY = "";
|
||||
expect(() => requireEnv("TEST_EMPTY", "cannot be empty")).toThrow("cannot be empty");
|
||||
delete process.env.TEST_EMPTY;
|
||||
});
|
||||
});
|
||||
|
||||
describe("optionalEnv", () => {
|
||||
test("returns value when set", () => {
|
||||
process.env.TEST_OPT = "world";
|
||||
expect(optionalEnv("TEST_OPT")).toBe("world");
|
||||
expect(optionalEnv("TEST_OPT", "default")).toBe("world");
|
||||
delete process.env.TEST_OPT;
|
||||
});
|
||||
|
||||
test("returns null when missing and no fallback", () => {
|
||||
expect(optionalEnv("TEST_MISSING_ABC")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns fallback when missing", () => {
|
||||
expect(optionalEnv("TEST_MISSING_ABC", "fallback")).toBe("fallback");
|
||||
});
|
||||
|
||||
test("returns fallback when empty string", () => {
|
||||
process.env.TEST_EMPTY2 = "";
|
||||
expect(optionalEnv("TEST_EMPTY2", "fb")).toBe("fb");
|
||||
expect(optionalEnv("TEST_EMPTY2")).toBeNull();
|
||||
delete process.env.TEST_EMPTY2;
|
||||
it("returns fallback when empty", () => {
|
||||
process.env.TEST_ENV_EMPTY = "";
|
||||
expect(env("TEST_ENV_EMPTY", "fb")).toBe("fb");
|
||||
delete process.env.TEST_ENV_EMPTY;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-util",
|
||||
"version": "0.4.5",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
/**
|
||||
* Read a required environment variable. Throws with `message` if missing or empty.
|
||||
* Read an environment variable with a required fallback default.
|
||||
* Returns the env value if set and non-empty, otherwise returns `fallback`.
|
||||
*
|
||||
* Every env var in a bundle must have a sensible default — bundles must run
|
||||
* without any env vars set. Env vars are overrides, not requirements.
|
||||
*/
|
||||
export function requireEnv(name: string, message: string): string {
|
||||
export function env(name: string, fallback: string): string {
|
||||
const value = process.env[name];
|
||||
if (value === undefined || value === "") {
|
||||
throw new Error(message);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an optional environment variable. Returns `fallback` if missing or empty.
|
||||
*/
|
||||
export function optionalEnv(name: string, fallback: string): string;
|
||||
export function optionalEnv(name: string): string | null;
|
||||
export function optionalEnv(name: string, fallback?: string): string | null {
|
||||
const value = process.env[name];
|
||||
if (value === undefined || value === "") {
|
||||
return fallback ?? null;
|
||||
return fallback;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export {
|
||||
encodeCrockfordBase32Bits,
|
||||
encodeUint64AsCrockford,
|
||||
} from "./base32.js";
|
||||
export { optionalEnv, requireEnv } from "./env.js";
|
||||
export { env } from "./env.js";
|
||||
export { createLogger } from "./logger.js";
|
||||
export { mergeRefsWithContentHash, normalizeRefsField } from "./refs-field.js";
|
||||
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
|
||||
|
||||
@@ -10,7 +10,7 @@ while IFS= read -r match; do
|
||||
file="${match%%:*}"
|
||||
rest="${match#*:}"
|
||||
line="${rest%%:*}"
|
||||
tag=$(echo "$rest" | grep -oP '\.log\(\s*"\K[A-Za-z0-9]+')
|
||||
tag=$(echo "$rest" | sed -n 's/.*\.log( *"\([A-Za-z0-9]*\)".*/\1/p')
|
||||
if echo "$tag" | grep -qiE '[ILOU]'; then
|
||||
echo " ❌ ${file}:${line} tag \"${tag}\" contains invalid Crockford Base32 char (I/L/O/U)"
|
||||
BAD=1
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* publish-all.mjs — 小橘 🍊
|
||||
*
|
||||
* Replaces workspace:^ with pinned versions, publishes all packages
|
||||
* in dependency order, then restores workspace:^ references.
|
||||
*
|
||||
* Usage: node scripts/publish-all.mjs [--tag alpha] [--dry-run]
|
||||
*/
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const tag = args.includes("--tag") ? args[args.indexOf("--tag") + 1] : null;
|
||||
const dryRun = args.includes("--dry-run");
|
||||
|
||||
const publishOrder = [
|
||||
"workflow-protocol",
|
||||
"workflow-util",
|
||||
"workflow-runtime",
|
||||
"workflow-cas",
|
||||
"workflow-reactor",
|
||||
"workflow-register",
|
||||
"workflow-execute",
|
||||
"workflow-util-agent",
|
||||
"workflow-agent-cursor",
|
||||
"workflow-agent-hermes",
|
||||
"workflow-agent-llm",
|
||||
"workflow-agent-react",
|
||||
"workflow-template-develop",
|
||||
"workflow-template-solve-issue",
|
||||
"workflow-gateway",
|
||||
"cli-workflow",
|
||||
];
|
||||
|
||||
const root = new URL("..", import.meta.url).pathname;
|
||||
const originals = new Map();
|
||||
|
||||
// Step 1: Collect all package versions
|
||||
const versions = new Map();
|
||||
for (const name of publishOrder) {
|
||||
const pkgPath = join(root, "packages", name, "package.json");
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
versions.set(pkg.name, pkg.version);
|
||||
}
|
||||
|
||||
// Step 2: Replace workspace:^ with pinned versions
|
||||
for (const name of publishOrder) {
|
||||
const pkgPath = join(root, "packages", name, "package.json");
|
||||
const raw = readFileSync(pkgPath, "utf-8");
|
||||
originals.set(pkgPath, raw);
|
||||
|
||||
const pkg = JSON.parse(raw);
|
||||
for (const depKey of ["dependencies", "devDependencies", "peerDependencies"]) {
|
||||
const deps = pkg[depKey];
|
||||
if (!deps) continue;
|
||||
for (const [depName, depVer] of Object.entries(deps)) {
|
||||
if (depVer === "workspace:^" && versions.has(depName)) {
|
||||
deps[depName] = `^${versions.get(depName)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
||||
}
|
||||
|
||||
// Step 3: Publish
|
||||
let failed = false;
|
||||
for (const name of publishOrder) {
|
||||
const pkgDir = join(root, "packages", name);
|
||||
const tagFlag = tag ? `--tag ${tag}` : "";
|
||||
const cmd = `npm publish --access public ${tagFlag}`;
|
||||
|
||||
if (dryRun) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const out = execSync(cmd, { cwd: pkgDir, stdio: "pipe" }).toString().trim();
|
||||
const _lastLine = out.split("\n").pop();
|
||||
} catch (_err) {
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Restore workspace:^ references
|
||||
for (const [pkgPath, raw] of originals) {
|
||||
writeFileSync(pkgPath, raw);
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user