Compare commits

...

37 Commits

Author SHA1 Message Date
xiaoju 43978360ff feat(workflow-util): add frontmatter markdown parser and validator
Phase 1 of RFC #351 — define AgentFrontmatter type, parseFrontmatterMarkdown()
and validateFrontmatter() with 45 tests.

- Built-in minimal YAML parser (no new deps)
- Never throws on malformed input — degrades gracefully
- All fields use T | null (no optional properties)

Refs #351
2026-05-19 04:41:56 +00:00
xiaomo ba90214af6 Merge pull request 'chore: internalize unused exports across all packages' (#283) from chore/audit-exports-cleanup into main 2026-05-16 09:59:57 +00:00
xiaoju 5bbac3e4f7 chore: internalize unused exports across all packages
Audit public API surfaces using reachability analysis from application
entry points (Worker, CLI, Dashboard). Symbols not reachable from any
application's customization tree are removed from package index.ts files.

Source files and internal usage are untouched — only the public export
surface is narrowed.

Changes by package:
- workflow-util: -7 exports (base32 internals, logger config types)
- workflow-cas: -12 exports (merkle internals, serialization details)
- workflow-execute: -24 exports (engine internals, LLM extract details)
- workflow-reactor: -4 exports (reactor config/invocation internals)
- workflow-register: -8 exports (redundant protocol re-exports, internal YAML fns)
- workflow-runtime: curated re-export subset (stop full protocol re-export)
- workflow-util-agent: -5 exports (internal agent helpers)
- workflow-agent-cursor: -1 export (validateCursorAgentConfig)
- workflow-agent-hermes: -1 export (validateHermesAgentConfig)

Note: workflow-protocol index.ts unchanged — downstream packages still
import removed symbols via internal paths. Protocol cleanup requires
updating workflow-runtime/src/types.ts first (separate PR).

Refs #273, #274, #275, #276, #277, #278, #279, #280, #281, #282
2026-05-16 09:58:56 +00:00
xiaomo 131021b1a7 Merge pull request 'chore: remove symlink dead code' (#271) from chore/remove-symlink-dead-code into main 2026-05-16 06:22:34 +00:00
xiaoju e42555fd9c chore: remove symlink dead code
Now that bundles are fully self-contained (no external @uncaged/* imports),
the symlink mechanism is no longer needed.

- Delete ensure-uncaged-workflow-symlink.ts
- Remove ensureUncagedWorkflowSymlink from all imports/exports
- Remove ExtractBundleExportsOptions type (storageRoot param)
- Simplify extractBundleExports to single-arg signature
- Clean up stale comments
2026-05-16 06:21:34 +00:00
xiaomo 3a26eb28e5 Merge pull request 'chore: make bundle fully self-contained, no external imports' (#270) from chore/no-external-bundle into main 2026-05-16 06:16:28 +00:00
xiaoju c1a17b707c chore: make bundle fully self-contained, no external imports
- Remove uncagedWorkflowExternals() from scaffold build script
- Remove --external from Bun.build config
- Tighten bundle validator: only Node built-ins allowed, all deps must be inlined
- Update skill.ts documentation

Bundles are now deterministic — same Node/Bun version = same behavior,
no dependency resolution at runtime.
2026-05-16 05:12:49 +00:00
xiaoju 4ea1e0d8a4 chore: publish 0.5.0-alpha.4 — unified env() API 2026-05-15 10:19:38 +00:00
xiaoju b1a9d2ec3f refactor: replace requireEnv/optionalEnv with env(name, fallback)
Bundles must run without env vars — env vars are overrides, not requirements.
Single function: env(name, fallback) always returns string with a default.

- Removed requireEnv and optionalEnv
- Updated bundle entries, tests, and skill docs

小橘 🍊
2026-05-15 10:07:49 +00:00
xiaoju 2b8707a706 style: use optionalEnv fallback param instead of ?? operator
小橘 🍊
2026-05-15 09:52:07 +00:00
xiaoju 241bfbf6d9 fix: hardcode absolute paths for agent CLI defaults in bundle entry
Validator requires absolute paths — bare command names like 'cursor-agent'
fail validation. Use `which` to discover the path, write it directly.

小橘 🍊
2026-05-15 09:49:42 +00:00
xiaoju 40530d757e fix: use optionalEnv with defaults for agent CLI paths in bundle entry
requireEnv causes silent worker crash when env vars are missing —
thread shows 0 steps with no error. Use optionalEnv + sensible defaults.

Also added pitfall guidance in skill author docs.

小橘 🍊
2026-05-15 09:25:39 +00:00
xiaoju 0f3661b566 refactor: unify GATEWAY_SECRET + DASHBOARD_API_KEY into WORKFLOW_DASHBOARD_SECRET 2026-05-15 09:12:12 +00:00
xingyue 9c44c709e9 fix(dashboard): replace broken partial-order layering with longest-path
The previous computeLayers used a reachability-based relation (a « b)
with depth tiebreaker to define a < b, then grouped nodes by == into
equivalence classes. However == is NOT an equivalence relation (fails
transitivity), making the grouping order-dependent and incorrect for
graphs with parallel branches.

Replace with standard Sugiyama longest-path layering:
1. DFS to detect and remove back-edges (break cycles)
2. Kahn's topological sort on the resulting DAG
3. rank(n) = max(rank(pred) + 1) for longest-path assignment
4. Group nodes by rank into layers

Also removes the experimental dagre layout strategy that was added
for comparison — longest-path produces better results for our
workflow graphs.
2026-05-15 14:48:02 +08:00
xingyue 8892ab9978 fix(dashboard): add left/right handles to end node for skip-forward edges 2026-05-15 14:15:35 +08:00
xingyue 7ec86d82a3 fix(dashboard): sidePath supports both feedback and skip-forward edges 2026-05-15 14:11:58 +08:00
xingyue f728b36e8d fix(dashboard): route skip-forward edges to side (planner→end was hidden) 2026-05-15 14:10:25 +08:00
xingyue 3431d3070b refactor(dashboard): reachability-based partial order for graph layout
Replace linear spine walk with a proper partial order:
- a « b = a ~> b AND NOT b ~> a (strict precedence)
- a ~ b = incomparable under «
- depth tiebreaker for incomparable nodes
- Equivalent nodes (same layer) placed side-by-side horizontally
2026-05-15 14:05:53 +08:00
xingyue 576df067d4 chore: remove generated bundle from git, fix biome format 2026-05-15 09:42:33 +08:00
xingyue a46a225d04 fix(dashboard): render system prompt as markdown 2026-05-15 09:41:52 +08:00
xiaoju f74b482cc0 chore: version 0.5.0-alpha.3, add publish-all script
- scripts/publish-all.mjs: pins workspace:^ before npm publish, restores after
- Workaround for bun publish workspace:^ resolution bug in pre mode

小橘 🍊
2026-05-15 01:37:27 +00:00
xiaomo 89abfdc257 Merge pull request 'feat(dashboard): show system prompt per role' (#269) from feat/show-system-prompt into main 2026-05-15 01:28:12 +00:00
xiaoju 77e395b913 chore: version 0.5.0-alpha.1
小橘 🍊
2026-05-15 01:27:54 +00:00
xingyue b65a006d45 feat(dashboard): show system prompt per role in workflow detail
- Add systemPrompt to WorkflowRoleDescriptor (protocol)
- Propagate systemPrompt through buildDescriptor and validateWorkflowDescriptor
- Display system prompt as collapsible <details> in RoleCard
2026-05-15 09:27:03 +08:00
xiaomo 5994548f0b Merge pull request 'chore: add .env.example with all supported env vars' (#207) from chore/205-env-example into main 2026-05-15 01:25:10 +00:00
xiaoju 0871ae54ea fix: remove unused WORKFLOW_LLM_API_KEY per review
No consumers after #262 removed llmProvider from bundle entries.
WORKFLOW_CURSOR_WORKSPACE has no env var counterpart, not added.

小橘 🍊
2026-05-15 01:24:29 +00:00
xiaoju 9576d69032 chore: enter changeset pre mode (alpha), version 0.5.0-alpha.0
小橘 🍊
2026-05-15 01:22:44 +00:00
xiaoju 64dadf114d fix: align .env.example with actual env vars
- Remove non-existent: WORKFLOW_LLM_BASE_URL, WORKFLOW_LLM_MODEL, WORKFLOW_CURSOR_LLM_PROVIDER
- Add missing: WORKFLOW_CURSOR_COMMAND (required for develop workflow)

小橘 🍊
2026-05-15 01:20:38 +00:00
xiaomo baaa1d1dc8 Merge pull request 'chore: biome format fix + pre-push hook' (#268) from chore/biome-fix-and-pre-push-hook into main 2026-05-15 01:20:38 +00:00
xiaoju 3074cd5f0c chore: add .env.example with all supported env vars
Documents all environment variables used across the workflow engine:
- LLM config (base URL, API key, model)
- Cursor agent config (model, timeout, provider)
- Hermes agent config (model, timeout)
- Storage and display options

Fixes #205
2026-05-15 01:20:38 +00:00
xingyue 15edc99c72 chore: add pre-push hook (check + test) and fix lint-log-tags for macOS 2026-05-15 09:11:39 +08:00
xingyue 153178c545 fix: biome format issues (12 errors) 2026-05-15 09:10:39 +08:00
xiaomo fac215bd21 Merge pull request 'chore(dashboard): remove unused _parentRequired param' (#267) from chore/remove-parentRequired-param into main 2026-05-15 00:30:51 +00:00
xingyue 9822e68c55 chore(dashboard): remove unused _parentRequired param from flattenSchema 2026-05-15 08:25:39 +08:00
xiaomo 764b73209e Merge pull request 'feat(dashboard): workflow detail 独立子页面' (#264) from feat/workflow-detail-layout into main 2026-05-15 00:23:45 +00:00
xiaomo e7987c4cd7 Merge pull request 'fix(cli): race condition in thread rm + flaky test' (#266) from fix/265-flaky-thread-rm into main 2026-05-15 00:21:16 +00:00
xingyue f5977c46c6 feat(dashboard): workflow detail as separate page with fixed graph sidebar
- Workflow list is now a simple clickable list (no expand/collapse)
- Clicking a workflow navigates to dedicated detail page (#client/workflows/name)
- Detail page: fixed graph sidebar on left, scrollable role cards on right
- Back button returns to workflow list
- Route: added workflowName to hash routing
2026-05-14 21:05:00 +08:00
91 changed files with 2543 additions and 945 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@uncaged/workflow-util": patch
---
Replace optionalEnv/requireEnv with unified env(name, fallback) API
+5
View File
@@ -0,0 +1,5 @@
---
"@uncaged/workflow-protocol": patch
---
fix: correct internal dependency versions for prerelease
+5
View File
@@ -0,0 +1,5 @@
---
"@uncaged/workflow-util-agent": patch
---
fix: include create-agent-adapter.ts in published src
+5
View File
@@ -0,0 +1,5 @@
---
"@uncaged/workflow-protocol": patch
---
fix: use npm publish with pinned deps instead of bun publish (workspace:^ resolution bug)
+30
View File
@@ -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"
]
}
+5
View File
@@ -0,0 +1,5 @@
---
"@uncaged/workflow-protocol": minor
---
feat: AgentFn<Opt> type boundary and createAgentAdapter bridging function (RFC #252)
+40
View File
@@ -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
View File
@@ -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!"
+3
View File
@@ -6,3 +6,6 @@ tsconfig.tsbuildinfo
.npmrc
bunfig.toml
xiaoju/
solve-issue-entry.ts
packages/workflow-template-develop/develop.esm.js
+7 -1
View File
@@ -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": {
+66
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/cli-workflow",
"version": "0.4.5",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
+1 -1
View File
@@ -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) {",
@@ -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;
}
+25 -2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-cursor",
"version": "0.4.5",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
@@ -12,7 +12,6 @@ import type { CursorAgentConfig } from "./types.js";
import { validateCursorAgentConfig } from "./validate-config.js";
export type { CursorAgentConfig } from "./types.js";
export { validateCursorAgentConfig } from "./validate-config.js";
function throwCursorSpawnError(error: SpawnCliError): never {
if (error.kind === "non_zero_exit") {
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-hermes",
"version": "0.4.5",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
@@ -14,7 +14,6 @@ const HERMES_DEFAULT_MAX_TURNS = 90;
type HermesAgentOpt = { prompt: string };
export type { HermesAgentConfig } from "./types.js";
export { validateHermesAgentConfig } from "./validate-config.js";
function throwHermesSpawnError(error: SpawnCliError): never {
if (error.kind === "non_zero_exit") {
+36
View File
@@ -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 -1
View File
@@ -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,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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-react",
"version": "0.4.5",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
+41
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-cas",
"version": "0.4.5",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
+2 -15
View File
@@ -1,29 +1,16 @@
export { createCasStore } from "./cas.js";
export { collectRefs } from "./collect-refs.js";
export { hashString, hashWorkflowBundleBytes } from "./hash.js";
export { hashWorkflowBundleBytes } from "./hash.js";
export {
createContentMerkleNode,
getContentMerklePayload,
parseMerkleNode,
putContentMerkleNode,
putStepMerkleNode,
putThreadMerkleNode,
serializeMerkleNode,
} from "./merkle.js";
export type { ParsedCasThreadNode } from "./nodes.js";
export {
isCasNodeYaml,
parseCasThreadNode,
putContentNodeWithRefs,
putStartNode,
putStateNode,
serializeCasNode,
} from "./nodes.js";
export { findReachableHashes } from "./reachable.js";
export type {
CasStore,
MerkleNode,
MerkleNodeType,
StepMerklePayload,
ThreadMerklePayload,
} from "./types.js";
export type { CasStore } from "./types.js";
+1
View File
@@ -122,6 +122,7 @@ export type WorkflowGraph = {
export type WorkflowRoleDescriptor = {
description: string;
systemPrompt: string;
schema: Record<string, unknown>;
};
+13 -2
View File
@@ -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,441 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { WorkflowDetail, WorkflowRoleDescriptor } 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;
}
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,
parentRequired: Set<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];
// Try to find a distinguishing literal (e.g. status: "approved")
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; // skip discriminator field
const subRows = flattenProperty(pName, pDef, depth + 1, childPrefix, `${keyPrefix}v${vi}-`, variantRequired);
rows.push(...subRows);
}
}
return rows;
}
// Handle regular object with properties
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,
});
// Recurse into nested object
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}-`, required));
}
// Recurse into array of objects
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}-`, new Set()));
}
}
// Recurse into oneOf
if (hasOneOf) {
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
rows.push(...flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, `${keyPrefix}${name}-`, required));
}
return rows;
}
function RoleCard({
roleName,
role,
}: {
roleName: string;
role: WorkflowRoleDescriptor;
}) {
const rows = flattenSchema(role.schema, 0, "", `${roleName}-`, new Set());
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>
)}
{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>
);
}
function ExpandedWorkflowBody({
cacheEntry,
staticNodeStates,
}: {
cacheEntry: DetailCacheEntry | undefined;
staticNodeStates: Map<string, NodeState>;
}) {
const [highlightedRole, setHighlightedRole] = useState<string | null>(null);
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const detail = cacheEntry?.status === "ok" ? cacheEntry.detail : null;
const descriptor = detail?.descriptor ?? null;
const roleEntries = descriptor !== null ? Object.entries(descriptor.roles) : [];
// All roles are "completed" (static view, all nodes lit) — must be before early returns
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]);
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 edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
const vc = detail !== null ? versionCount(detail) : 0;
const hasGraph = descriptor !== null && edgeCount > 0;
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 className="pt-3 border-t flex gap-4" style={{ borderColor: "var(--color-border)" }}>
{/* Left: graph sidebar */}
{hasGraph && (
<div
className="shrink-0"
style={{
width: 280,
position: "sticky",
top: 16,
height: "calc(100vh - 120px)",
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: workflow info + role cards */}
<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)" }}
>
<h3 className="text-base font-semibold mb-2" style={{ color: "var(--color-text)" }}>
{detail.name}
</h3>
<p className="text-sm whitespace-pre-wrap mb-3" style={{ color: "var(--color-text)" }}>
{descriptor !== null && descriptor.description !== ""
? descriptor.description
: descriptor !== null
? "—"
: "No descriptor available for this workflow version."}
</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>
{vc} version{vc !== 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>
);
}
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>;
@@ -450,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,
};
}
+61
View File
@@ -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 -1
View File
@@ -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>;
+2 -28
View File
@@ -1,48 +1,22 @@
export { createWorkflow } from "./engine/create-workflow.js";
export { executeThread } from "./engine/engine.js";
export {
FORK_BRANCH_ROLE,
prepareCasFork,
tryParseWorkflowResultRecord,
walkStateFramesNewestFirst,
} from "./engine/fork-thread.js";
export { garbageCollectCas } from "./engine/gc.js";
export { createThreadPauseGate } from "./engine/thread-pause-gate.js";
export type {
ThreadHistoryEntry,
ThreadIndex,
ThreadIndexEntry,
} from "./engine/threads-index.js";
export {
appendThreadHistoryEntry,
getBundleDir,
readThreadsIndex,
removeThreadEntry,
removeThreadHistoryEntries,
upsertThreadEntry,
writeThreadsIndex,
} from "./engine/threads-index.js";
export type {
CasForkPlan,
ChainState,
ExecuteThreadIo,
ExecuteThreadOptions,
ForkContinuationOptions,
GcResult,
PrefilledDiskStep,
SupervisorDecision,
ThreadPauseGate,
} from "./engine/types.js";
export { EMPTY_CHAIN_STATE } from "./engine/types.js";
export type { GcResult } from "./engine/types.js";
export { getWorkerHostScriptPath } from "./engine/worker-entry-path.js";
export type { ExtractFn, LlmError, LlmExtractArgs } from "./extract/index.js";
export {
createExtract,
extractFunctionToolFromZodSchema,
llmErrorToCause,
llmExtract,
} from "./extract/index.js";
export { createExtract } from "./extract/index.js";
export { type WorkflowAdapterOptions, workflowAdapter } from "./workflow-adapter.js";
/** @deprecated Use {@link workflowAdapter} instead. */
export { type WorkflowAsAgentOptions, workflowAsAgent } from "./workflow-as-agent.js";
@@ -69,7 +69,7 @@ async function resolveWorkflowBundle(workflowName: string, storageRoot: string,
}
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
const bundleExportsResult = await extractBundleExports(bundlePath, { storageRoot });
const bundleExportsResult = await extractBundleExports(bundlePath);
if (!bundleExportsResult.ok) {
throw new Error(String(bundleExportsResult.error));
}
+10
View File
@@ -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 -1
View File
@@ -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;
+14 -10
View File
@@ -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,
+1 -1
View File
@@ -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`
+27
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-protocol",
"version": "0.4.5",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
+1
View File
@@ -24,6 +24,7 @@ export type WorkflowRoleSchema = Record<string, unknown>;
export type WorkflowRoleDescriptor = {
description: string;
systemPrompt: string;
schema: WorkflowRoleSchema;
};
+35
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-reactor",
"version": "0.4.5",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
-4
View File
@@ -3,10 +3,6 @@ export { createThreadReactor } from "./thread-reactor.js";
export type {
ChatMessage,
LlmFn,
StructuredToolSpec,
ThreadReactorConfig,
ThreadReactorFn,
ThreadReactorInvokeArgs,
ToolCall,
ToolDefinition,
} from "./types.js";
+41
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-register",
"version": "0.4.5",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
@@ -35,11 +35,12 @@ export function buildDescriptor<M extends RoleMeta>(
): WorkflowDescriptor {
const roles: WorkflowDescriptor["roles"] = {};
for (const [key, roleDef] of Object.entries(def.roles) as Array<
[string, { description: string; schema: z.ZodType }]
[string, { description: string; systemPrompt: string; schema: z.ZodType }]
>) {
const rawJsonSchema = z.toJSONSchema(roleDef.schema) as Record<string, unknown>;
roles[key] = {
description: roleDef.description,
systemPrompt: roleDef.systemPrompt,
schema: stripJsonSchemaMeta(rawJsonSchema),
};
}
@@ -1,7 +1,7 @@
import { pathToFileURL } from "node:url";
/**
* Dynamic-import a workflow bundle path (see {@link extractBundleExports} — symlink must exist first).
* Dynamic-import a workflow bundle path.
*/
export async function importWorkflowBundleModule(bundlePath: string): Promise<unknown> {
return import(pathToFileURL(bundlePath).href);
@@ -37,9 +37,6 @@ function isAllowedImportSpecifier(spec: string): boolean {
if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("file:")) {
return false;
}
if (spec.startsWith("@uncaged/workflow")) {
return true;
}
return isBuiltin(spec);
}
@@ -294,7 +291,7 @@ function validateImportDeclaration(node: ImportDeclaration): string | null {
return "only static string import specifiers are allowed";
}
if (!isAllowedImportSpecifier(spec)) {
return `disallowed import specifier "${spec}" (only Node built-ins and @uncaged/workflow-* packages are allowed)`;
return `disallowed import specifier "${spec}" (only Node built-ins are allowed; all other dependencies must be bundled)`;
}
return null;
}
@@ -309,7 +306,7 @@ function validateExportSource(
return staticMessage;
}
if (!isAllowedImportSpecifier(spec)) {
return `${disallowedPrefix} "${spec}" (only Node built-ins and @uncaged/workflow-* packages are allowed)`;
return `${disallowedPrefix} "${spec}" (only Node built-ins are allowed; all other dependencies must be bundled)`;
}
return null;
}
@@ -1,56 +0,0 @@
import { mkdir, readlink, symlink, unlink } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
/** This module lives in `@uncaged/workflow-register/src/bundle`; grandparent dir is the package root. */
function installedWorkflowPackageDir(): string {
return fileURLToPath(new URL("../..", import.meta.url));
}
/**
* Resolve sibling @uncaged/* package directory relative to workflow-register.
* In a monorepo workspace layout the sibling packages live next to workflow-register.
*/
function siblingPackageDir(packageName: string): string {
const registerRoot = installedWorkflowPackageDir();
return path.resolve(registerRoot, "..", packageName);
}
async function ensureSymlink(linkDir: string, name: string, target: string): Promise<void> {
const linkPath = path.join(linkDir, name);
await mkdir(linkDir, { recursive: true });
try {
const existing = await readlink(linkPath);
const normalizedExisting = path.resolve(linkDir, existing);
if (normalizedExisting === target) {
return;
}
await unlink(linkPath);
} catch (e) {
const errObj = e as NodeJS.ErrnoException;
if (errObj.code !== "ENOENT" && errObj.code !== "EINVAL") {
throw e;
}
}
const linkType = process.platform === "win32" ? "junction" : "dir";
await symlink(target, linkPath, linkType);
}
/**
* Ensures `<storageRoot>/node_modules/@uncaged/*` symlinks point at installed packages
* so workflow bundles loaded from `<storageRoot>/bundles/*.esm.js` can resolve their imports.
*/
export async function ensureUncagedWorkflowSymlink(storageRoot: string): Promise<void> {
const linkDir = path.join(storageRoot, "node_modules", "@uncaged");
const packages = [
{ name: "workflow", dir: siblingPackageDir("workflow") },
{ name: "workflow-runtime", dir: siblingPackageDir("workflow-runtime") },
{ name: "workflow-cas", dir: siblingPackageDir("workflow-cas") },
{ name: "workflow-protocol", dir: siblingPackageDir("workflow-protocol") },
];
for (const pkg of packages) {
await ensureSymlink(linkDir, pkg.name, pkg.dir);
}
}
@@ -1,21 +1,15 @@
import type { WorkflowFn } from "@uncaged/workflow-protocol";
import { err, ok, type Result } from "@uncaged/workflow-util";
import { importWorkflowBundleModule } from "./bundle-import-env.js";
import { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
import type { ExtractBundleExportsOptions, ExtractedBundleExports } from "./types.js";
import type { ExtractedBundleExports } from "./types.js";
import { validateWorkflowDescriptor } from "./workflow-descriptor.js";
/** Load a workflow `.esm.js` bundle and read its named exports (`run`, `descriptor`). */
export async function extractBundleExports(
bundlePath: string,
options: ExtractBundleExportsOptions = { storageRoot: null },
): Promise<Result<ExtractedBundleExports, string>> {
let modUnknown: unknown;
try {
if (options.storageRoot !== null) {
await ensureUncagedWorkflowSymlink(options.storageRoot);
}
// Dynamic import required: user bundle path resolved at runtime
modUnknown = await importWorkflowBundleModule(bundlePath);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
@@ -1,17 +1,10 @@
export { buildDescriptor } from "./build-descriptor.js";
export { importWorkflowBundleModule } from "./bundle-import-env.js";
export { validateWorkflowBundle } from "./bundle-validator.js";
export { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
export { extractBundleExports } from "./extract-bundle-exports.js";
export { stringifyWorkflowDescriptor } from "./generate-descriptor.js";
export type {
ExtractBundleExportsOptions,
ExtractedBundleExports,
WorkflowBundleValidationInput,
WorkflowDescriptor,
WorkflowGraph,
WorkflowGraphEdge,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
} from "./types.js";
export { validateWorkflowDescriptor } from "./workflow-descriptor.js";
@@ -20,8 +20,3 @@ export type ExtractedBundleExports = {
run: WorkflowFn;
descriptor: WorkflowDescriptor;
};
export type ExtractBundleExportsOptions = {
/** When set, ensures `node_modules/@uncaged/workflow` exists under this root before import. */
storageRoot: string | null;
};
@@ -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,
};
}
-10
View File
@@ -1,16 +1,9 @@
export type {
ExtractBundleExportsOptions,
ExtractedBundleExports,
WorkflowBundleValidationInput,
WorkflowDescriptor,
WorkflowGraph,
WorkflowGraphEdge,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
} from "./bundle/index.js";
export {
buildDescriptor,
ensureUncagedWorkflowSymlink,
extractBundleExports,
importWorkflowBundleModule,
stringifyWorkflowDescriptor,
@@ -21,18 +14,15 @@ export type { ProviderConfig, ResolvedModel } from "./config/index.js";
export { resolveModel, splitProviderModelRef } from "./config/index.js";
export type {
WorkflowConfig,
WorkflowHistoryEntry,
WorkflowRegistryEntry,
WorkflowRegistryFile,
} from "./registry/index.js";
export {
getRegisteredWorkflow,
listRegisteredWorkflowNames,
parseWorkflowRegistryYaml,
readWorkflowRegistry,
registerWorkflowVersion,
rollbackWorkflowToHistoryHash,
stringifyWorkflowRegistryYaml,
unregisterWorkflow,
workflowRegistryPath,
writeWorkflowRegistry,
+40
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-runtime",
"version": "0.4.5",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
+2 -10
View File
@@ -2,7 +2,6 @@ export { buildThreadContext } from "./build-context.js";
export { createWorkflow } from "./create-workflow.js";
export { err, ok } from "./result.js";
export type {
AdapterBinding,
AdapterFn,
AgentContext,
AgentFn,
@@ -14,7 +13,6 @@ export type {
ModeratorCondition,
ModeratorContext,
ModeratorTable,
ModeratorTransition,
Result,
RoleDefinition,
RoleFn,
@@ -22,17 +20,11 @@ export type {
RoleOutput,
RoleResult,
RoleStep,
StartStep,
ThreadContext,
WorkflowCompletion,
WorkflowDefinition,
WorkflowDescriptor,
WorkflowFn,
WorkflowGraph,
WorkflowGraphEdge,
WorkflowResult,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
WorkflowRuntime,
} from "./types.js";
export { END, START } from "./types.js";
} from "@uncaged/workflow-protocol";
export { END, START } from "@uncaged/workflow-protocol";
@@ -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,23 +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 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,
});
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",
+36
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-util-agent",
"version": "0.4.5",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
+3 -4
View File
@@ -1,5 +1,4 @@
export { buildAgentPrompt, buildThreadInput } from "./build-agent-prompt.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 { buildThreadInput } from "./build-agent-prompt.js";
export { createAgentAdapter } from "./create-agent-adapter.js";
export type { SpawnCliError } from "./spawn-cli.js";
export { spawnCli } from "./spawn-cli.js";
+36
View File
@@ -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
+13 -37
View File
@@ -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;
});
});
@@ -0,0 +1,343 @@
import { describe, expect, it } from "vitest";
import type { AgentFrontmatter } from "../src/index.js";
import { parseFrontmatterMarkdown, validateFrontmatter } from "../src/index.js";
// ── parseFrontmatterMarkdown ─────────────────────────────────────────────────
describe("parseFrontmatterMarkdown", () => {
describe("no frontmatter", () => {
it("returns null frontmatter and full text as body when no fence", () => {
const raw = "Just some markdown text.\n\n## Section\n\nContent.";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter).toBeNull();
expect(result.body).toBe(raw);
});
it("returns null frontmatter when --- appears mid-document", () => {
const raw = "# Heading\n\n---\n\nContent.";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter).toBeNull();
expect(result.body).toBe(raw);
});
it("returns null frontmatter when opening fence is not followed by newline", () => {
const raw = "--- inline content ---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter).toBeNull();
expect(result.body).toBe(raw);
});
it("returns null frontmatter when no closing fence", () => {
const raw = "---\nstatus: done\nbody without close";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter).toBeNull();
expect(result.body).toBe(raw);
});
it("handles empty string", () => {
const result = parseFrontmatterMarkdown("");
expect(result.frontmatter).toBeNull();
expect(result.body).toBe("");
});
});
describe("full frontmatter document", () => {
it("parses all fields from a well-formed document", () => {
const raw = `---
status: done
next: reviewer
confidence: 0.9
artifacts:
- src/foo.ts
- src/bar.ts
scope: thread
---
## Summary
Everything looks good.`;
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter).not.toBeNull();
const fm = result.frontmatter!;
expect(fm.status).toBe("done");
expect(fm.next).toBe("reviewer");
expect(fm.confidence).toBe(0.9);
expect(fm.artifacts).toEqual(["src/foo.ts", "src/bar.ts"]);
expect(fm.scope).toBe("thread");
expect(result.body).toBe("## Summary\n\nEverything looks good.");
});
it("strips leading newline from body", () => {
const raw = "---\nstatus: done\n---\n\nbody here";
const result = parseFrontmatterMarkdown(raw);
expect(result.body).toBe("body here");
});
it("body is empty string when nothing after closing fence", () => {
const raw = "---\nstatus: done\n---\n";
const result = parseFrontmatterMarkdown(raw);
expect(result.body).toBe("");
});
it("body is empty string when document ends exactly at closing fence", () => {
const raw = "---\nstatus: done\n---";
const result = parseFrontmatterMarkdown(raw);
expect(result.body).toBe("");
});
});
describe("status field", () => {
it.each([
"done",
"needs_input",
"in_progress",
"failed",
] as const)('parses status "%s"', (status) => {
const raw = `---\nstatus: ${status}\n---\nbody`;
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.status).toBe(status);
});
it("returns null status for unknown value", () => {
const raw = "---\nstatus: unknown_value\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.status).toBeNull();
});
it("returns null status when omitted", () => {
const raw = "---\nconfidence: 0.5\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.status).toBeNull();
});
});
describe("confidence field", () => {
it("parses integer as number", () => {
const raw = "---\nconfidence: 1\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.confidence).toBe(1);
});
it("parses decimal", () => {
const raw = "---\nconfidence: 0.75\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.confidence).toBe(0.75);
});
it("returns null when omitted", () => {
const raw = "---\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.confidence).toBeNull();
});
it("returns null for non-numeric value", () => {
const raw = "---\nconfidence: high\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.confidence).toBeNull();
});
});
describe("artifacts field", () => {
it("parses block sequence", () => {
const raw = "---\nartifacts:\n - a.ts\n - b.ts\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.artifacts).toEqual(["a.ts", "b.ts"]);
});
it("parses inline sequence", () => {
const raw = "---\nartifacts: [a.ts, b.ts]\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.artifacts).toEqual(["a.ts", "b.ts"]);
});
it("returns empty array when omitted", () => {
const raw = "---\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.artifacts).toEqual([]);
});
it("wraps single scalar in array", () => {
const raw = "---\nartifacts: only-one.ts\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.artifacts).toEqual(["only-one.ts"]);
});
});
describe("scope field", () => {
it('parses scope "role"', () => {
const raw = "---\nscope: role\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.scope).toBe("role");
});
it('parses scope "thread"', () => {
const raw = "---\nscope: thread\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.scope).toBe("thread");
});
it('defaults to "role" when omitted', () => {
const raw = "---\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.scope).toBe("role");
});
it('defaults to "role" for unknown scope value', () => {
const raw = "---\nscope: global\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.scope).toBe("role");
});
});
describe("next field", () => {
it("parses a role name", () => {
const raw = "---\nnext: planner\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.next).toBe("planner");
});
it("returns null when omitted", () => {
const raw = "---\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.next).toBeNull();
});
});
describe("unknown fields", () => {
it("ignores unknown keys silently", () => {
const raw = "---\nunknown_field: some_value\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.status).toBe("done");
});
});
describe("YAML comments", () => {
it("ignores YAML comment lines", () => {
const raw = "---\n# this is a comment\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.status).toBe("done");
});
});
describe("empty frontmatter block", () => {
it("parses empty frontmatter and uses all defaults", () => {
const raw = "---\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter).not.toBeNull();
const fm = result.frontmatter!;
expect(fm.status).toBeNull();
expect(fm.next).toBeNull();
expect(fm.confidence).toBeNull();
expect(fm.artifacts).toEqual([]);
expect(fm.scope).toBe("role");
expect(result.body).toBe("body");
});
});
});
// ── validateFrontmatter ──────────────────────────────────────────────────────
function validFm(overrides: Partial<AgentFrontmatter> = {}): AgentFrontmatter {
return {
status: "done",
next: null,
confidence: null,
artifacts: [],
scope: "role",
...overrides,
};
}
describe("validateFrontmatter", () => {
it("returns no errors for a fully valid frontmatter", () => {
const errors = validateFrontmatter(validFm());
expect(errors).toHaveLength(0);
});
it("returns no errors when all nullable fields are null", () => {
const fm: AgentFrontmatter = {
status: null,
next: null,
confidence: null,
artifacts: [],
scope: "role",
};
expect(validateFrontmatter(fm)).toHaveLength(0);
});
describe("confidence validation", () => {
it("accepts 0.0", () => {
expect(validateFrontmatter(validFm({ confidence: 0 }))).toHaveLength(0);
});
it("accepts 1.0", () => {
expect(validateFrontmatter(validFm({ confidence: 1 }))).toHaveLength(0);
});
it("rejects value below 0", () => {
const errors = validateFrontmatter(validFm({ confidence: -0.1 }));
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("confidence");
});
it("rejects value above 1", () => {
const errors = validateFrontmatter(validFm({ confidence: 1.01 }));
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("confidence");
});
});
describe("next validation", () => {
it("accepts a simple role name", () => {
expect(validateFrontmatter(validFm({ next: "reviewer" }))).toHaveLength(0);
});
it("accepts kebab-case role name", () => {
expect(validateFrontmatter(validFm({ next: "code-reviewer" }))).toHaveLength(0);
});
it("rejects role name with whitespace", () => {
const errors = validateFrontmatter(validFm({ next: "role name" }));
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("next");
});
});
describe("artifacts validation", () => {
it("accepts non-empty path strings", () => {
expect(
validateFrontmatter(validFm({ artifacts: ["src/foo.ts", "src/bar.ts"] })),
).toHaveLength(0);
});
it("rejects empty string artifact entries", () => {
const errors = validateFrontmatter(validFm({ artifacts: [""] }));
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("artifacts");
});
it("rejects whitespace-only artifact entries", () => {
const errors = validateFrontmatter(validFm({ artifacts: [" "] }));
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("artifacts");
});
});
describe("multiple errors", () => {
it("reports multiple violations at once", () => {
const fm: AgentFrontmatter = {
status: "done",
next: "bad role",
confidence: 2,
artifacts: [""],
scope: "role",
};
const errors = validateFrontmatter(fm);
const fields = errors.map((e) => e.field);
expect(fields).toContain("next");
expect(fields).toContain("confidence");
expect(fields).toContain("artifacts");
});
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-util",
"version": "0.4.5",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
+7 -16
View File
@@ -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;
}
@@ -0,0 +1,291 @@
import type {
AgentFrontmatter,
FrontmatterScope,
FrontmatterStatus,
FrontmatterValidationError,
ParsedFrontmatterMarkdown,
} from "./types.js";
// ── YAML frontmatter extractor ───────────────────────────────────────────────
const FENCE = "---";
/**
* Split a raw agent response into a YAML string (or null) and a markdown body.
*
* A frontmatter block MUST:
* 1. Start at character position 0 with `---` (no leading whitespace / BOM).
* 2. Be closed by a second `---` on its own line.
*
* Anything that doesn't match this shape is returned verbatim as the body.
*/
function splitFrontmatter(raw: string): { yaml: string | null; body: string } {
if (!raw.startsWith(FENCE)) {
return { yaml: null, body: raw };
}
const rest = raw.slice(FENCE.length);
// The opening `---` must be followed immediately by a newline (or end-of-string).
if (rest.length > 0 && rest[0] !== "\n" && rest[0] !== "\r") {
return { yaml: null, body: raw };
}
// Consume the newline after the opening fence so that `afterOpen` starts at the
// first line of YAML content (not a leading empty line).
const afterOpen = rest.startsWith("\n") ? rest.slice(1) : rest;
const closeIndex = afterOpen.indexOf(`\n${FENCE}`);
if (closeIndex === -1) {
// Also handle the edge case where frontmatter is empty: `---\n---`
if (afterOpen.startsWith(FENCE)) {
const afterClose = afterOpen.slice(FENCE.length);
const body = afterClose.replace(/^\n+/, "");
return { yaml: "", body };
}
return { yaml: null, body: raw };
}
const yaml = afterOpen.slice(0, closeIndex);
// Skip past `\n---` and strip any leading blank separator lines from the body.
const afterClose = afterOpen.slice(closeIndex + 1 + FENCE.length);
const body = afterClose.replace(/^\n+/, "");
return { yaml, body };
}
// ── Minimal YAML scalar parser ───────────────────────────────────────────────
//
// We intentionally avoid a full YAML library dependency inside workflow-util.
// The frontmatter schema is flat and uses only scalars + simple string lists.
// This parser handles exactly what the spec needs and nothing more.
type YamlValue = string | number | boolean | null | string[];
function parseYamlScalar(raw: string): YamlValue {
const trimmed = raw.trim();
// Quoted string
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
const lower = trimmed.toLowerCase();
if (lower === "true") return true;
if (lower === "false") return false;
if (lower === "null" || lower === "~" || lower === "") return null;
const num = Number(trimmed);
if (!Number.isNaN(num) && trimmed !== "") return num;
return trimmed;
}
function collectBlockSequence(
lines: string[],
startIdx: number,
): { items: string[]; nextIdx: number } {
const items: string[] = [];
let i = startIdx;
while (i < lines.length) {
const itemTrimmed = (lines[i] ?? "").trimStart();
if (!itemTrimmed.startsWith("- ")) break;
items.push(itemTrimmed.slice(2).trim());
i++;
}
return { items, nextIdx: i };
}
function parseInlineSequence(restTrimmed: string): string[] {
const inner = restTrimmed.slice(1, -1);
return inner
.split(",")
.map((s) => s.trim())
.filter((s) => s !== "");
}
function parseKeyValue(
lines: string[],
i: number,
): { key: string; value: YamlValue; nextIdx: number } | null {
const line = lines[i] ?? "";
if (line.trim() === "" || line.trimStart().startsWith("#")) {
return null;
}
const colonIdx = line.indexOf(":");
if (colonIdx === -1) {
return null;
}
const key = line.slice(0, colonIdx).trim();
const restTrimmed = line.slice(colonIdx + 1).trim();
if (restTrimmed === "") {
const { items, nextIdx } = collectBlockSequence(lines, i + 1);
return { key, value: items, nextIdx };
}
if (restTrimmed.startsWith("[") && restTrimmed.endsWith("]")) {
return { key, value: parseInlineSequence(restTrimmed), nextIdx: i + 1 };
}
return { key, value: parseYamlScalar(restTrimmed), nextIdx: i + 1 };
}
/**
* Parse a minimal flat YAML document. Only supports:
* - Scalar key: value pairs
* - Block sequences under a key (items prefixed with ` - `)
*
* Returns a plain object. Throws on structural errors.
*/
function parseMinimalYaml(yaml: string): Record<string, YamlValue> {
const result: Record<string, YamlValue> = {};
const lines = yaml.split("\n");
let i = 0;
while (i < lines.length) {
const entry = parseKeyValue(lines, i);
if (entry === null) {
i++;
continue;
}
result[entry.key] = entry.value;
i = entry.nextIdx;
}
return result;
}
// ── Field coercers ───────────────────────────────────────────────────────────
const VALID_STATUS: readonly FrontmatterStatus[] = ["done", "needs_input", "in_progress", "failed"];
const VALID_SCOPE: readonly FrontmatterScope[] = ["role", "thread"];
function coerceStatus(raw: YamlValue): FrontmatterStatus | null {
if (raw === null || raw === undefined) return null;
const s = String(raw).trim().toLowerCase();
return VALID_STATUS.includes(s as FrontmatterStatus) ? (s as FrontmatterStatus) : null;
}
function coerceNext(raw: YamlValue): string | null {
if (raw === null || raw === undefined) return null;
const s = String(raw).trim();
return s === "" ? null : s;
}
function coerceConfidence(raw: YamlValue): number | null {
if (raw === null || raw === undefined) return null;
const n = typeof raw === "number" ? raw : Number(String(raw).trim());
if (Number.isNaN(n)) return null;
return n;
}
function coerceArtifacts(raw: YamlValue): readonly string[] {
if (raw === null || raw === undefined) return [];
if (Array.isArray(raw)) return raw.map(String).filter((s) => s !== "");
const s = String(raw).trim();
return s === "" ? [] : [s];
}
function coerceScope(raw: YamlValue): FrontmatterScope {
if (raw === null || raw === undefined) return "role";
const s = String(raw).trim().toLowerCase();
return VALID_SCOPE.includes(s as FrontmatterScope) ? (s as FrontmatterScope) : "role";
}
// ── Public API ───────────────────────────────────────────────────────────────
/**
* Parse a raw agent response string into structured frontmatter + body.
*
* - Never throws: malformed YAML is silently treated as "no frontmatter".
* - The returned `frontmatter` is `null` when no valid `---…---` block was found.
* - Unknown YAML keys are silently ignored.
* - Invalid scalar values for known keys are coerced to their null/default.
*/
export function parseFrontmatterMarkdown(raw: string): ParsedFrontmatterMarkdown {
const { yaml, body } = splitFrontmatter(raw);
if (yaml === null) {
return { frontmatter: null, body };
}
let fields: Record<string, YamlValue>;
try {
fields = parseMinimalYaml(yaml);
} catch {
// Unparseable YAML → treat as no frontmatter; keep full raw as body.
return { frontmatter: null, body: raw };
}
const frontmatter: AgentFrontmatter = {
status: coerceStatus(fields.status ?? null),
next: coerceNext(fields.next ?? null),
confidence: coerceConfidence(fields.confidence ?? null),
artifacts: coerceArtifacts(fields.artifacts ?? null),
scope: coerceScope(fields.scope ?? null),
};
return { frontmatter, body };
}
/**
* Validate a parsed `AgentFrontmatter` and return a list of violations.
*
* An empty array means the frontmatter is valid.
*
* Validated constraints:
* - `status` — must be one of the FrontmatterStatus literals (if non-null)
* - `confidence` — must be in [0.0, 1.0] (if non-null)
* - `next` — must be a non-empty string with no whitespace (if non-null)
* - `artifacts` — each entry must be a non-empty string
* - `scope` — must be one of the FrontmatterScope literals
*/
export function validateFrontmatter(
frontmatter: AgentFrontmatter,
): readonly FrontmatterValidationError[] {
const errors: FrontmatterValidationError[] = [];
if (frontmatter.status !== null && !VALID_STATUS.includes(frontmatter.status)) {
errors.push({
field: "status",
message: `invalid status "${frontmatter.status}"; must be one of: ${VALID_STATUS.join(", ")}`,
});
}
if (frontmatter.confidence !== null) {
if (frontmatter.confidence < 0 || frontmatter.confidence > 1) {
errors.push({
field: "confidence",
message: `confidence ${frontmatter.confidence} is out of range; must be between 0.0 and 1.0 inclusive`,
});
}
}
if (frontmatter.next !== null) {
if (frontmatter.next.trim() === "") {
errors.push({ field: "next", message: "next must be a non-empty string when present" });
} else if (/\s/.test(frontmatter.next)) {
errors.push({
field: "next",
message: `next "${frontmatter.next}" must not contain whitespace`,
});
}
}
for (const artifact of frontmatter.artifacts) {
if (artifact.trim() === "") {
errors.push({ field: "artifacts", message: "artifact entries must be non-empty strings" });
break;
}
}
if (!VALID_SCOPE.includes(frontmatter.scope)) {
errors.push({
field: "scope",
message: `invalid scope "${frontmatter.scope}"; must be one of: ${VALID_SCOPE.join(", ")}`,
});
}
return errors;
}
@@ -0,0 +1,8 @@
export { parseFrontmatterMarkdown, validateFrontmatter } from "./frontmatter-markdown.js";
export type {
AgentFrontmatter,
FrontmatterScope,
FrontmatterStatus,
FrontmatterValidationError,
ParsedFrontmatterMarkdown,
} from "./types.js";
@@ -0,0 +1,111 @@
/**
* Frontmatter Markdown — agent output format (RFC #351 Phase 1).
*
* An agent response is a Markdown document with an optional YAML frontmatter
* block at the top. The frontmatter carries structured signals that the
* moderator and engine can consume without running a full LLM extract pass.
*
* Wire format:
*
* ---
* status: done
* next: reviewer
* confidence: 0.9
* artifacts:
* - src/foo.ts
* scope: role
* ---
*
* ... free-form markdown body ...
*
* All frontmatter fields are optional at the parse level. `validateFrontmatter`
* enforces the constraints documented on each field below.
*/
// ── Vocabulary types ─────────────────────────────────────────────────────────
/**
* High-level signal from the agent about where work stands.
*
* - `done` — role completed its objective; moderator may advance
* - `needs_input` — agent is blocked and requires human or peer clarification
* - `in_progress` — work is underway but the agent chose to yield early
* - `failed` — agent cannot complete the task and explains why in the body
*/
export type FrontmatterStatus = "done" | "needs_input" | "in_progress" | "failed";
/**
* Scope of frontmatter signals.
*
* - `role` — signals apply to the current role execution only (default)
* - `thread` — signals are suggestions for the entire thread moderator
*/
export type FrontmatterScope = "role" | "thread";
// ── Core frontmatter schema ──────────────────────────────────────────────────
/**
* Parsed and validated frontmatter from an agent response.
*
* All fields use explicit `T | null` (no optional `?:` per convention).
*/
export type AgentFrontmatter = {
/**
* Completion status signal from the agent.
* Null when omitted — engine treats it as "done" for backward compatibility.
*/
status: FrontmatterStatus | null;
/**
* Suggested next role name for the moderator.
* The moderator is NOT obligated to follow this — it is advisory only.
* Null when the agent has no preference.
*/
next: string | null;
/**
* Agent's self-assessed confidence in its output (0.0 – 1.0 inclusive).
* Null when omitted.
*/
confidence: number | null;
/**
* Relative file paths or CAS hashes the agent considers its primary outputs.
* Used for GC ref-tracing and human-readable summaries.
* Empty array when omitted (never null — an absent list is an empty list).
*/
artifacts: readonly string[];
/**
* Scope of the frontmatter signals.
* Defaults to "role" when omitted.
*/
scope: FrontmatterScope;
};
// ── Parse output ─────────────────────────────────────────────────────────────
/**
* Result of `parseFrontmatterMarkdown`: the structured frontmatter (if present)
* and the body (everything after the closing `---` fence, or the whole input
* if no frontmatter was found).
*/
export type ParsedFrontmatterMarkdown = {
/**
* Parsed frontmatter fields. Null when no frontmatter block was detected
* (i.e. the document does not start with `---`).
*/
frontmatter: AgentFrontmatter | null;
/** Markdown body with frontmatter block stripped. Leading newline removed. */
body: string;
};
// ── Validation error ─────────────────────────────────────────────────────────
export type FrontmatterValidationError =
| { field: "status"; message: string }
| { field: "next"; message: string }
| { field: "confidence"; message: string }
| { field: "artifacts"; message: string }
| { field: "scope"; message: string };
+14 -9
View File
@@ -1,14 +1,19 @@
export { err, ok } from "@uncaged/workflow-protocol";
export { encodeUint64AsCrockford } from "./base32.js";
export { env } from "./env.js";
export {
CROCKFORD_BASE32_ALPHABET,
decodeCrockfordBase32Bits,
decodeCrockfordToUint64,
encodeCrockfordBase32Bits,
encodeUint64AsCrockford,
} from "./base32.js";
export { optionalEnv, requireEnv } from "./env.js";
parseFrontmatterMarkdown,
validateFrontmatter,
} from "./frontmatter-markdown/index.js";
export type {
AgentFrontmatter,
FrontmatterScope,
FrontmatterStatus,
FrontmatterValidationError,
ParsedFrontmatterMarkdown,
} from "./frontmatter-markdown/index.js";
export { createLogger } from "./logger.js";
export { mergeRefsWithContentHash, normalizeRefsField } from "./refs-field.js";
export { normalizeRefsField } from "./refs-field.js";
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
export type { CreateLoggerOptions, LogFn, LoggerSink, Result } from "./types.js";
export type { LogFn, Result } from "./types.js";
export { generateUlid } from "./ulid.js";
+1 -1
View File
@@ -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
+94
View File
@@ -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);
}