Compare commits

..

39 Commits

Author SHA1 Message Date
xingyue ca644dabaa chore: bump all packages to 0.4.0, fix exports for publish
- All @uncaged/* packages → 0.4.0
- Internal deps: workspace:* → workspace:^ (resolves to ^0.4.0 on publish)
- Fix exports: add 'bun' condition for local dev (src), 'import' for consumers (dist)
- Remove stale 'main: src/index.ts' from 6 packages
- Fix publish.sh topo sort to match workspace:^ prefix

星月 <xingyue@shazhou.work>
2026-05-13 20:46:00 +08:00
xiaomo 9d9c00df98 Merge pull request 'chore: remove link-all.sh' (#240) from chore/remove-link-all into main 2026-05-13 10:16:22 +00:00
xiaoju c85980f604 Merge pull request 'chore: merge publish-all.sh into publish.sh' (#238) from chore/merge-publish-scripts into main 2026-05-13 09:52:43 +00:00
xingyue eff5fb332a chore: merge publish.sh and publish-all.sh (#237)
- Topological sort from publish-all.sh replaces hardcoded order
- bun publish directly (no manual workspace:* replacement/restoration)
- bun run build + bun test (not npm run)
- --dry-run support (skips git commit/push)
- Delete publish-all.sh
- Update package.json scripts

Closes #237

Signed-off-by: 星月 <xingyue@shazhou.work>
2026-05-13 17:51:49 +08:00
xingyue 658a4a24ef chore: merge publish-all.sh into publish.sh
- Use bun pm pack for workspace:* resolution (no manual replace/restore)
- Topological sort replaces hardcoded PUBLISH_ORDER
- Registry unified to uncaged org
- Delete scripts/publish-all.sh
- Add --dry-run flag support

Closes #237
2026-05-13 17:44:06 +08:00
xingyue aabfd90a87 Merge pull request 'fix: auto-discover publishable packages + pre-publish test gate' (#236) from fix/auto-discover-publish into main 2026-05-13 09:42:55 +00:00
xingyue 0207f93303 fix: npm test → bun test per review 2026-05-13 17:42:11 +08:00
xingyue e1423f196b refactor: delegate publish to publish-all.sh, remove duplicated discovery+topo logic
- Remove inline auto-discover + Kahn's topo sort from publish.sh (was duplicating publish-all.sh)
- Remove inline publish loop + smoke test (publish-all.sh handles both)
- publish.sh now: bump version → replace workspace:* → build → test → call publish-all.sh → restore → commit
- Net: -97 lines, single source of truth for package discovery and publish order
2026-05-13 17:32:08 +08:00
xingyue ae6954a02f fix(publish): auto-discover packages + pre-publish test gate
What: Replace hardcoded PUBLISH_ORDER with auto-discovery of all
non-private packages, sorted by topological dependency order (Kahn's).
Add a test gate (npm test) after build, before publish.

Why: The manual list was missing workflow-gateway and workflow-agent-react,
causing them to never get published. Any future package additions would
have the same problem.

Changes:
- scripts/publish.sh: Replace static PUBLISH_ORDER array with node script
  that reads all packages/*/package.json, filters out private, and
  topologically sorts by @uncaged/* internal dependencies
- scripts/publish.sh: Add npm test step between build and publish,
  aborting on failure
2026-05-13 17:22:50 +08:00
xingyue aede8f7613 chore: publish v0.3.21
小橘 <xiaoju@shazhou.work>
2026-05-13 17:10:39 +08:00
xiaomo 6d1e0498ba Merge pull request 'refactor(dashboard): replace ELK with custom spine layout' (#235) from refactor/dashboard-custom-spine-layout into main 2026-05-13 09:03:34 +00:00
xingyue 6cce5e2593 chore: publish v0.3.20
小橘 <xiaoju@shazhou.work>
2026-05-13 17:00:43 +08:00
xingyue d3a7ed9062 chore: publish v0.3.19
小橘 <xiaoju@shazhou.work>
2026-05-13 16:56:55 +08:00
xingyue e7f733c393 refactor(dashboard): replace ELK with custom spine layout
What: Replace ELK layout engine with a hand-written spine layout that
topologically sorts nodes into a vertical main path with feedback edges
routed to the right side.

Why: ELK's layered algorithm spreads the graph too wide when handling
feedback (back) edges, causing fitView to shrink nodes until text is
unreadable. Our workflow graphs are predominantly linear pipelines with
feedback loops — a custom layout handles this topology much better.

Changes:
- packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts:
  rewrite from async ELK to synchronous spine layout — topo-sort extracts
  main path, nodes stack vertically, feedback edges get right-side routing
- packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx:
  add custom SVG path for feedback edges (right-side arc with Q curves),
  use typed isFeedback/isSelfLoop fields from ConditionEdgeData
- packages/workflow-dashboard/src/components/workflow-graph/types.ts:
  rename elkLabelX/Y to labelX/Y, add isFeedback and isSelfLoop fields
- packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx:
  remove ReactFlowProvider/useReactFlow/useEffect fitView workaround
  (no longer needed — layout is synchronous), simplify component
- packages/workflow-dashboard/package.json: remove elkjs and dagre deps
2026-05-13 16:54:04 +08:00
xingyue d4bb4a9324 Merge pull request 'fix(cli): point bin to dist/cli.js instead of src/cli.ts' (#234) from fix/cli-bin-path into main 2026-05-13 08:43:41 +00:00
xingyue e4900b6fd6 fix(cli): keep bin pointing to src/cli.ts, add src to files
The actual issue was that 'files' only included dist/, so src/ was
excluded from the published package. bun can run .ts natively — no
need to point bin at compiled dist/cli.js.

Fix: add 'src' to files array so it ships with the package.
2026-05-13 16:43:07 +08:00
xiaomo 39540d9ae8 Merge pull request 'fix(dashboard): address ELK layout review feedback' (#233) from fix/dashboard-elk-review-feedback into main 2026-05-13 08:40:32 +00:00
xingyue 10899364d4 fix(cli): point bin to dist/cli.js instead of src/cli.ts
The bin entry pointed to src/cli.ts but only dist/ is published,
causing 'Cannot find module cli-dispatch.js' on global install.
2026-05-13 16:38:54 +08:00
xingyue dc5fdd7358 fix(dashboard): address ELK layout review feedback
What: Fix three non-blocking issues from PR #232 review.

Why: Code quality — unhandled promise rejection risk, type safety,
and project convention compliance.

Changes:
- packages/workflow-dashboard/src/components/workflow-graph/types.ts:
  add elkLabelX/elkLabelY fields to ConditionEdgeData type (number | null,
  not optional — per project no-optional-properties rule)
- packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts:
  remove 'as ConditionEdgeData' type assertion (now unnecessary),
  add .catch() to computeLayout promise
- packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx:
  remove redundant inline type extension, use ConditionEdgeData directly

Ref: PR #232 review comments
2026-05-13 16:37:07 +08:00
xiaoju bb1293f6b9 fix: add exports field to 6 packages for proper type resolution
Packages without exports.types pointed main/types to src/ which
doesn't exist in published tarballs. Now all packages have:
  exports."." = { types: dist/index.d.ts, import: src/index.ts }

Bump to 0.3.18.
2026-05-13 08:29:36 +00:00
xiaomo 55b3b61498 Merge pull request 'feat(dashboard): switch graph layout from Dagre to ELK' (#232) from feat/dashboard-elk-layout into main 2026-05-13 08:28:24 +00:00
xingyue 484ed520cd feat(dashboard): switch graph layout from Dagre to ELK
What: Replace Dagre layout engine with ELK (Eclipse Layout Kernel) for
workflow graph visualization in the dashboard.

Why: Dagre lacks support for edge label placement and orthogonal edge
routing, causing condition labels to overlap with nodes. ELK provides
proper label positioning, better edge routing, and more compact layouts.

Changes:
- packages/workflow-dashboard/package.json: add elkjs dependency
- packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts:
  rewrite layout from Dagre to async ELK with layered algorithm,
  orthogonal routing, reduced spacing for compactness
- packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx:
  use ELK-computed label positions, show all labels including FALLBACK,
  switch to getSmoothStepPath for all edges
- packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx:
  wrap in ReactFlowProvider, add fitView on async layout change,
  key-based remount for layout stability
- packages/workflow-dashboard/src/components/workflow-list.tsx:
  left-right layout (info left, graph right), fix toggleExpanded
  React 18 batching bug, increase graph container height
2026-05-13 16:26:03 +08:00
xiaoju 497f03c747 chore: bump all packages to 0.3.17 2026-05-13 08:04:32 +00:00
xiaoju cfe4543d39 refactor!: remove deprecated Agent types, introduce Adapter-first API
BREAKING CHANGES:
- Remove AgentFn, AgentFnResult, AgentBinding from workflow-protocol
- Remove wrapAgentAsAdapter from workflow-util-agent
- workflowAsAgent → workflowAdapter (old name kept as deprecated re-export)

New APIs:
- createTextAdapter(producer) — bridges text-producing functions to AdapterFn
- TextProducerFn, TextAdapterResult types
- workflowAdapter() — direct AdapterFn for child workflow delegation

All agent packages (cursor, hermes, llm) now return AdapterFn directly,
no wrapping needed. Bundle entries simplified accordingly.

小橘 🍊(NEKO Team)
2026-05-13 08:03:27 +00:00
xiaoju 399b967c59 refactor: reduce cognitive complexity in dispatch.ts and shell-exec.ts
- Extract helpers from promptSecret/onData (32→~4)
- Extract sub-functions from collectInteractiveSetup (36→~8)
- Extract classifyExecError from shell-exec handler (17→~3)
- Replace all non-null assertions with safe .at() access

0 biome errors, 0 warnings.
2026-05-13 07:37:47 +00:00
xiaoju 061926b86a chore: fix all biome lint errors
- Auto-fix string concatenation → template literals
- Remove unused imports
- Prefix unused function with underscore
- Format fixes across multiple files
2026-05-13 07:26:11 +00:00
xiaoju acb0ebed97 chore: add @types/node for node:* module declarations 2026-05-13 07:21:43 +00:00
xiaoju d5d7be6100 chore: add files field to all packages, bump to 0.3.16
Excludes tsconfig.json and source files from published packages.
Fixes TypeScript errors when consuming packages via bun.
2026-05-13 07:19:49 +00:00
xiaoju 1566a43395 chore: bump all packages to 0.3.15 2026-05-13 07:04:12 +00:00
xiaoju afbde4573a chore: add bunfig.toml to gitignore (contains registry token) 2026-05-13 06:55:16 +00:00
xiaoju 63e447fc3d chore: unify npm registry to uncaged org
publish-all.sh now targets the same org as .npmrc.

小橘 🍊
2026-05-13 06:49:30 +00:00
xiaoju 34fcbf29cb chore: bump workflow-util and workflow-util-agent to 0.3.14
小橘 🍊
2026-05-13 06:12:29 +00:00
xiaoju 256799fcfd chore: bump workflow-util and workflow-util-agent to 0.3.12
小橘 🍊
2026-05-13 06:04:53 +00:00
xiaoju 21cf3db111 feat(util): extract requireEnv/optionalEnv to workflow-util
- requireEnv(name, message) — throws with custom error message
- optionalEnv(name, fallback?) — returns fallback or null
- Update develop and solve-issue bundle entries to use shared helpers
- Remove inline requireEnv/optionalEnv and wrapAgentAsAdapter usage
- Add tests for both functions

小橘 🍊
2026-05-13 06:02:17 +00:00
xiaomo ed38543db4 Merge pull request 'docs(skill): add authoring pitfalls to skill author topic' (#231) from fix/skill-author-pitfalls into main 2026-05-13 03:59:50 +00:00
xiaomo 78771fbebc Merge pull request 'fix(publish-all): regenerate lockfile before pack' (#230) from fix/publish-lockfile-regen into main 2026-05-13 03:59:42 +00:00
xiaoju c15f58bdeb docs(skill): add authoring pitfalls to skill author topic
Add ModeratorTable syntax, AdapterFn/AdapterBinding types, lazy init
pattern, bundle import restrictions, and descriptor requirements.

Knowledge from smoke test discoveries — these are the most common
mistakes when writing workflow bundles.

小橘 <xiaoju@shazhou.work>
2026-05-13 03:57:49 +00:00
xiaoju 6d4bf108bb fix(publish-all): regenerate lockfile before pack
After bumping versions, bun pm pack reads the old bun.lock and resolves
workspace:* to stale versions. Now deletes bun.lock and runs bun install
before the pack loop to ensure correct resolution.

小橘 <xiaoju@shazhou.work>
2026-05-13 03:52:10 +00:00
xingyue 5b7c9b844b fix(engine): abort signal races gen.next() to fix flaky kill test (#209)
Root cause: executeThread awaited gen.next() without racing against
the abort signal. When a workflow bundle awaited a long setTimeout
between yields, the engine could not respond to kill until the
Promise resolved — causing the kill test to flake when the thread
completed before kill arrived.

Fix: Promise.race gen.next() with an abort listener so kill takes
effect immediately, even mid-yield. Also move the bundle's delay
to after the first yield (between planner and coder) to ensure the
thread is killable while running.

Closes #209
2026-05-13 11:31:40 +08:00
70 changed files with 1406 additions and 920 deletions
+1
View File
@@ -5,3 +5,4 @@ bun.lock
tsconfig.tsbuildinfo
.npmrc
bunfig.toml
-2
View File
@@ -1,2 +0,0 @@
[test]
pathIgnorePatterns = ["dist/**"]
+3 -2
View File
@@ -13,11 +13,12 @@
"link": "./scripts/link-all.sh",
"link:consume": "./scripts/link-all.sh --consume",
"link:unlink": "./scripts/link-all.sh --unlink",
"publish:gitea": "./scripts/publish-all.sh",
"publish:gitea:dry": "./scripts/publish-all.sh --dry-run"
"publish:gitea": "./scripts/publish.sh patch",
"publish:gitea:dry": "./scripts/publish.sh --dry-run patch"
},
"devDependencies": {
"@biomejs/biome": "^2.4.14",
"@types/node": "^25.7.0",
"@types/xxhashjs": "^0.2.4",
"bun-types": "^1.3.13"
}
@@ -91,7 +91,7 @@ describe("init workspace", () => {
"RoleDefinition",
"WorkflowDefinition",
"ModeratorTable",
"AgentFn",
"AdapterFn",
"ExtractFn",
"RoleMeta",
]) {
@@ -70,10 +70,10 @@ const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (input, options) {
await new Promise((r) => setTimeout(r, 600));
const cas = options.cas;
let h = await putContentMerkleNode(cas, "plan");
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
await new Promise((r) => setTimeout(r, 10000));
h = await putContentMerkleNode(cas, "code");
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
return { returnCode: 0, summary: "done" };
+13 -8
View File
@@ -1,18 +1,23 @@
{
"name": "@uncaged/cli-workflow",
"version": "0.3.11",
"version": "0.4.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"bin": {
"uncaged-workflow": "src/cli.ts"
},
"dependencies": {
"@uncaged/workflow-gateway": "workspace:*",
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-util": "workspace:*",
"@uncaged/workflow-cas": "workspace:*",
"@uncaged/workflow-execute": "workspace:*",
"@uncaged/workflow-register": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-gateway": "workspace:^",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-cas": "workspace:^",
"@uncaged/workflow-execute": "workspace:^",
"@uncaged/workflow-register": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"hono": "^4.12.18",
"yaml": "^2.8.4"
},
@@ -5,7 +5,6 @@ import { err, ok, type Result } from "@uncaged/workflow-protocol";
import { pathExists } from "../../fs-utils.js";
import type { CmdInitWorkspaceSuccess } from "./types.js";
import { validateWorkspaceSegment } from "./validate.js";
function rootPackageJson(workspaceName: string): string {
return `${JSON.stringify(
@@ -91,7 +90,7 @@ function agentsMd(): string {
|------|----------------|------|
| **Workspace** | 仓库根(\`package.json\`\`workspaces: ["templates/*", "workflows"]\`) | Bun monorepo:统一管理本地模板包与 workflow 实例 |
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`\`src/moderator.ts\`\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **ModeratorTable**),**不绑定**具体 Agent |
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AgentFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AdapterFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下放绑定与打包入口。
@@ -101,10 +100,10 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
- **RoleDefinition<Meta>**:纯数据——\`description\`\`systemPrompt\`\`schema\`(Zod v4)。不含执行逻辑。
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **ModeratorTable**(声明式路由表)。
- **ModeratorTable**:从 \`START\` 与各角色名映射到有序 transition 列表(条件 + 下一角色或 \`END\`);可序列化,供描述符提取 **graph**。
- **AgentFn**:\`(ctx: AgentContext) => Promise<string>\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Agent 都可使用)。
- **AdapterFn**:接收系统提示词与 Zod schema,返回角色执行函数(RoleFn)
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Adapter 都可使用)。
引擎循环简述:按 **ModeratorTable** 选下一角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
引擎循环简述:按 **ModeratorTable** 选下一角色 → **Adapter** 产出 typed meta → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
## 3. 开发流程
@@ -112,7 +111,7 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`description\`
3. **编写 ModeratorTable**:为 \`START\` 与各角色声明 transition(\`FALLBACK\` 或命名条件 + \`check\`)。
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / table 导出)。
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AdapterFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
## 4. 编码规范
@@ -1,7 +1,7 @@
import { existsSync } from "node:fs";
import { resolve as resolvePath } from "node:path";
import { stdin as input, stdout as output } from "node:process";
import { createInterface } from "node:readline/promises";
import { resolve as resolvePath } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow-protocol";
@@ -10,6 +10,7 @@ import { createLogger } from "@uncaged/workflow-util";
import { printCliError, printCliLine, printCliWarn } from "../../cli-output.js";
const setupDispatchLog = createLogger({ sink: { kind: "stderr" } });
import { loadPresetProviders } from "./preset-providers.js";
import { cmdSetup, printSetupSummary } from "./setup.js";
import type { SetupCliArgs } from "./types.js";
@@ -154,11 +155,67 @@ async function promptLine(
return raw.trim();
}
type SecretInputState = {
buf: string;
rawWasSet: boolean;
onData: (chunk: string) => void;
fulfill: (value: string) => void;
};
function isLineTerminator(c: string): boolean {
return c === "\n" || c === "\r" || c === "\u0004";
}
function handleLineTerminator(state: SecretInputState): void {
if (process.stdin.isTTY) {
process.stdin.setRawMode(state.rawWasSet);
}
process.stdin.pause();
process.stdin.removeListener("data", state.onData);
process.stdout.write("\n");
state.fulfill(state.buf.trim());
}
function handleBackspace(state: SecretInputState): void {
if (state.buf.length > 0) {
state.buf = state.buf.slice(0, -1);
process.stdout.write("\b \b");
}
}
function handleInterrupt(rawWasSet: boolean): void {
if (process.stdin.isTTY) {
process.stdin.setRawMode(rawWasSet);
}
process.exit(130);
}
function isBackspace(c: string): boolean {
return c === "\u007F" || c === "\b";
}
/** Process a single character in secret input. Returns "done" to stop reading. */
function processSecretChar(c: string, state: SecretInputState): "done" | "skip" | "append" {
if (isLineTerminator(c)) {
handleLineTerminator(state);
return "done";
}
if (isBackspace(c)) {
handleBackspace(state);
return "skip";
}
if (c === "\u0003") {
handleInterrupt(state.rawWasSet);
}
state.buf += c;
process.stdout.write("*");
return "append";
}
/** Read a line with terminal echo disabled (for secrets). */
async function promptSecret(label: string): Promise<string> {
process.stdout.write(label);
return new Promise((fulfill) => {
let buf = "";
const rawWasSet = process.stdin.isRaw;
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
@@ -166,46 +223,22 @@ async function promptSecret(label: string): Promise<string> {
process.stdin.resume();
process.stdin.setEncoding("utf8");
const state: SecretInputState = { buf: "", rawWasSet, fulfill, onData: () => {} };
const onData = (chunk: string) => {
for (const c of chunk.toString()) {
if (c === "\n" || c === "\r" || c === "\u0004") {
if (process.stdin.isTTY) {
process.stdin.setRawMode(rawWasSet);
}
process.stdin.pause();
process.stdin.removeListener("data", onData);
process.stdout.write("\n");
fulfill(buf.trim());
return;
}
if (c === "\u007F" || c === "\b") {
if (buf.length > 0) {
buf = buf.slice(0, -1);
process.stdout.write("\b \b");
}
continue;
}
if (c === "\u0003") {
if (process.stdin.isTTY) {
process.stdin.setRawMode(rawWasSet);
}
process.exit(130);
}
buf += c;
process.stdout.write("*");
if (processSecretChar(c, state) === "done") return;
}
};
state.onData = onData;
process.stdin.on("data", onData);
});
}
/** Fetch available models from an OpenAI-compatible /models endpoint. */
async function fetchAvailableModels(
baseUrl: string,
apiKey: string,
): Promise<string[]> {
const url = baseUrl.replace(/\/+$/, "") + "/models";
async function fetchAvailableModels(baseUrl: string, apiKey: string): Promise<string[]> {
const url = `${baseUrl.replace(/\/+$/, "")}/models`;
try {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
@@ -228,139 +261,158 @@ async function fetchAvailableModels(
.filter((id) => !NON_CHAT_RE.test(id))
.sort();
} catch (e) {
setupDispatchLog("V8NQ4JT6", `fetch models failed: ${e instanceof Error ? e.message : String(e)}`);
setupDispatchLog(
"V8NQ4JT6",
`fetch models failed: ${e instanceof Error ? e.message : String(e)}`,
);
return [];
}
}
type PresetProvider = ReturnType<typeof loadPresetProviders>[number];
function printProviderMenu(presets: readonly PresetProvider[]): void {
const numWidth = String(presets.length + 1).length;
printCliLine("Select a provider:\n");
for (let i = 0; i < presets.length; i++) {
const p = presets.at(i);
if (!p) continue;
const num = String(i + 1).padStart(numWidth);
printCliLine(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
}
const customNum = String(presets.length + 1).padStart(numWidth);
printCliLine(` ${customNum}) Custom (enter name and URL manually)`);
printCliLine("");
}
async function selectProvider(
rl: { question: (q: string) => Promise<string> },
presets: readonly PresetProvider[],
): Promise<Result<{ provider: string; baseUrl: string }, string>> {
const choice = await promptLine(rl, `Choose [1-${presets.length + 1}]: `);
const choiceNum = Number.parseInt(choice, 10);
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > presets.length + 1) {
return err(`invalid choice: ${choice}`);
}
if (choiceNum <= presets.length) {
const selected = presets.at(choiceNum - 1);
if (!selected) return err(`invalid choice: ${choice}`);
printCliLine(`\n → ${selected.label} (${selected.baseUrl})\n`);
return ok({ provider: selected.name, baseUrl: selected.baseUrl });
}
const provider = await promptLine(rl, "Provider name (e.g. my-proxy): ");
if (provider === "") return err("provider name must not be empty");
const baseUrl = await promptLine(rl, "OpenAI-compatible API base URL: ");
if (baseUrl === "") return err("base URL must not be empty");
return ok({ provider, baseUrl });
}
function printModelList(models: string[]): void {
const cols = process.stdout.columns || 80;
const nw = String(models.length).length;
const prefixLen = nw + 4;
const maxModelLen = Math.max(...models.map((m) => m.length));
const cellWidth = prefixLen + maxModelLen + 2;
const numCols = Math.max(1, Math.floor(cols / cellWidth));
for (let i = 0; i < models.length; i += numCols) {
const cells: string[] = [];
for (let j = i; j < Math.min(i + numCols, models.length); j++) {
const num = String(j + 1).padStart(nw);
const model = models.at(j) ?? "";
cells.push(` ${num}) ${model.padEnd(maxModelLen + 2)}`);
}
printCliLine(cells.join(""));
}
}
async function selectModel(
rl: { question: (q: string) => Promise<string> },
models: string[],
): Promise<Result<string, string>> {
if (models.length > 0) {
printCliLine(`\nAvailable models (${models.length}):\n`);
printModelList(models);
printCliLine(`\nChoose a number, or type a model name directly.`);
const modelInput = await promptLine(rl, `Default model [1-${models.length}]: `);
if (modelInput === "") return err("default model must not be empty");
const modelNum = Number.parseInt(modelInput, 10);
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
return ok(models.at(modelNum - 1) ?? modelInput);
}
return ok(modelInput);
}
printCliWarn("Could not fetch models (API may not support /models endpoint).");
const modelInput = await promptLine(rl, `Default model (e.g. qwen-plus, gpt-4o): `);
if (modelInput === "") return err("default model must not be empty");
return ok(modelInput);
}
async function selectWorkspace(rl: {
question: (q: string) => Promise<string>;
}): Promise<string | null> {
while (true) {
const wsPath = await promptLine(
rl,
"\nWorkflow workspace path (default: ./workflows, type 'skip' to skip): ",
);
if (wsPath.toLowerCase() === "skip") return null;
const candidate = wsPath === "" ? "./workflows" : wsPath;
const resolved = resolvePath(process.cwd(), candidate);
if (existsSync(resolved)) {
printCliWarn(`directory already exists: ${resolved}`);
printCliLine("Please enter a different path, or type 'skip' to skip.");
continue;
}
return candidate;
}
}
function stripProviderPrefix(model: string): string {
if (model.includes("/")) {
return model.split("/").pop() ?? model;
}
return model;
}
async function collectInteractiveSetup(): Promise<Result<SetupCliArgs, string>> {
const rl = createInterface({ input, output });
try {
printCliLine("Configure the LLM provider that workflow agents will use.\n");
const presets = loadPresetProviders();
const numWidth = String(presets.length + 1).length;
printCliLine("Select a provider:\n");
for (let i = 0; i < presets.length; i++) {
const p = presets[i]!;
const num = String(i + 1).padStart(numWidth);
printCliLine(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
}
const customNum = String(presets.length + 1).padStart(numWidth);
printCliLine(` ${customNum}) Custom (enter name and URL manually)`);
printCliLine("");
printProviderMenu(presets);
const choice = await promptLine(rl, `Choose [1-${presets.length + 1}]: `);
const choiceNum = Number.parseInt(choice, 10);
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > presets.length + 1) {
const providerResult = await selectProvider(rl, presets);
if (!providerResult.ok) {
rl.close();
return err(`invalid choice: ${choice}`);
return providerResult;
}
const { provider, baseUrl } = providerResult.value;
let provider: string;
let baseUrl: string;
if (choiceNum <= presets.length) {
const selected = presets[choiceNum - 1]!;
provider = selected.name;
baseUrl = selected.baseUrl;
printCliLine(`\n → ${selected.label} (${baseUrl})\n`);
} else {
provider = await promptLine(rl, "Provider name (e.g. my-proxy): ");
if (provider === "") {
return err("provider name must not be empty");
}
baseUrl = await promptLine(rl, "OpenAI-compatible API base URL: ");
if (baseUrl === "") {
return err("base URL must not be empty");
}
}
// Close readline before raw-mode secret prompt, reopen after.
rl.close();
const apiKey = await promptSecret("API key for this provider: ");
if (apiKey === "") {
return err("API key must not be empty");
}
if (apiKey === "") return err("API key must not be empty");
const rl2 = createInterface({ input, output });
// Try to list available models from the provider.
printCliLine("\nFetching available models...");
const models = await fetchAvailableModels(baseUrl, apiKey);
let selectedModel: string;
if (models.length > 0) {
printCliLine(`\nAvailable models (${models.length}):\n`);
const cols = process.stdout.columns || 80;
const nw = String(models.length).length; // number width
// Each cell: " <num>) <model> " — prefix is 2 + nw + 2 = nw+4
const prefixLen = nw + 4;
const maxModelLen = Math.max(...models.map((m) => m.length));
const cellWidth = prefixLen + maxModelLen + 2; // +2 gap between columns
const numCols = Math.max(1, Math.floor(cols / cellWidth));
for (let i = 0; i < models.length; i += numCols) {
const cells: string[] = [];
for (let j = i; j < Math.min(i + numCols, models.length); j++) {
const num = String(j + 1).padStart(nw);
cells.push(` ${num}) ${(models[j]!).padEnd(maxModelLen + 2)}`);
}
printCliLine(cells.join(""));
}
printCliLine(`\nChoose a number, or type a model name directly.`);
const modelInput = await promptLine(rl2, `Default model [1-${models.length}]: `);
if (modelInput === "") {
rl2.close();
return err("default model must not be empty");
}
const modelNum = Number.parseInt(modelInput, 10);
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
selectedModel = models[modelNum - 1]!;
} else {
// Treat as a literal model name.
selectedModel = modelInput;
}
} else {
printCliWarn("Could not fetch models (API may not support /models endpoint).");
const modelInput = await promptLine(rl2, `Default model (e.g. qwen-plus, gpt-4o): `);
if (modelInput === "") {
rl2.close();
return err("default model must not be empty");
}
selectedModel = modelInput;
const modelResult = await selectModel(rl2, models);
if (!modelResult.ok) {
rl2.close();
return modelResult;
}
// Strip provider prefix if user included one (e.g. pasted "MiniMax/MiniMax-M2.7").
const bare = selectedModel.includes("/") ? selectedModel.split("/").pop()! : selectedModel;
const bare = stripProviderPrefix(modelResult.value);
const defaultModel = `${provider}/${bare}`;
printCliLine(`${defaultModel}`);
let initWorkspaceName: string | null = null;
// Loop until a valid workspace path is provided or the user skips.
while (true) {
const wsPath = await promptLine(
rl2,
"\nWorkflow workspace path (default: ./workflows, type 'skip' to skip): ",
);
if (wsPath.toLowerCase() === "skip") {
break;
}
const candidate = wsPath === "" ? "./workflows" : wsPath;
// Validate path before passing to cmdSetup.
const resolved = resolvePath(process.cwd(), candidate);
if (existsSync(resolved)) {
printCliWarn(`directory already exists: ${resolved}`);
printCliLine("Please enter a different path, or type 'skip' to skip.");
continue;
}
initWorkspaceName = candidate;
break;
}
const initWorkspaceName = await selectWorkspace(rl2);
rl2.close();
return ok({
provider,
baseUrl,
apiKey,
defaultModel,
initWorkspaceName,
});
return ok({ provider, baseUrl, apiKey, defaultModel, initWorkspaceName });
} catch (e) {
return err(e instanceof Error ? e.message : String(e));
}
@@ -5,45 +5,43 @@ import { parse as parseYaml } from "yaml";
import type { PresetProvider } from "./types.js";
type RawPresetEntry = {
name: unknown;
label: unknown;
baseUrl: unknown;
name: unknown;
label: unknown;
baseUrl: unknown;
};
function isRawEntry(v: unknown): v is RawPresetEntry {
if (typeof v !== "object" || v === null) return false;
const o = v as Record<string, unknown>;
return typeof o.name === "string" && typeof o.label === "string" && typeof o.baseUrl === "string";
if (typeof v !== "object" || v === null) return false;
const o = v as Record<string, unknown>;
return typeof o.name === "string" && typeof o.label === "string" && typeof o.baseUrl === "string";
}
let cached: ReadonlyArray<PresetProvider> | null = null;
export function loadPresetProviders(): ReadonlyArray<PresetProvider> {
if (cached !== null) return cached;
if (cached !== null) return cached;
const yamlPath = join(import.meta.dirname, "providers.yaml");
const raw = readFileSync(yamlPath, "utf8");
const parsed: unknown = parseYaml(raw);
const yamlPath = join(import.meta.dirname, "providers.yaml");
const raw = readFileSync(yamlPath, "utf8");
const parsed: unknown = parseYaml(raw);
if (!Array.isArray(parsed)) {
throw new Error(`providers.yaml: expected array, got ${typeof parsed}`);
}
if (!Array.isArray(parsed)) {
throw new Error(`providers.yaml: expected array, got ${typeof parsed}`);
}
const result: PresetProvider[] = [];
for (const entry of parsed) {
if (!isRawEntry(entry)) {
throw new Error(`providers.yaml: invalid entry: ${JSON.stringify(entry)}`);
}
result.push({
name: entry.name as string,
label: entry.label as string,
baseUrl: entry.baseUrl as string,
});
}
const result: PresetProvider[] = [];
for (const entry of parsed) {
if (!isRawEntry(entry)) {
throw new Error(`providers.yaml: invalid entry: ${JSON.stringify(entry)}`);
}
result.push({
name: entry.name as string,
label: entry.label as string,
baseUrl: entry.baseUrl as string,
});
}
cached = result;
return result;
cached = result;
return result;
}
@@ -13,8 +13,6 @@ import type { CmdSetupSuccess, SetupCliArgs } from "./types.js";
const setupLog = createLogger({ sink: { kind: "stderr" } });
function mergeWorkflowConfig(
prev: WorkflowConfig | null,
input: SetupCliArgs,
+86 -16
View File
@@ -183,35 +183,63 @@ How to build, test, and publish workflow bundles for uncaged-workflow.
A workflow bundle is a single ESM file (\`.esm.js\`) that exports:
\`\`\`typescript
// Required exports
// Required named exports (no default export)
export const descriptor: WorkflowDescriptor;
export const run: WorkflowRun;
export const run: WorkflowFn;
\`\`\`
## WorkflowDescriptor
Serialized metadata for the registry (per-role JSON Schema plus a static routing graph):
Serialized metadata for the registry. Every role must include both \`description\` and \`schema\` (JSON Schema object). The graph uses an edges array where each edge has \`from\`, \`to\`, and \`condition\`.
\`\`\`typescript
type WorkflowDescriptor = {
description: string;
roles: Record<string, { description: string; schema: unknown /* JSON Schema */ }>;
roles: Record<string, {
description: string;
schema: object; // JSON Schema — use z.toJSONSchema(zodSchema) to generate
}>;
graph: {
edges: Array<{
from: string;
to: string;
condition: string;
conditionDescription: string | null;
from: string; // role name, or "__start__"
to: string; // role name, or "__end__"
condition: string; // e.g. "FALLBACK"
conditionDescription?: string | null;
}>;
};
};
\`\`\`
## WorkflowRun
**descriptor is static data** — it is read at \`workflow add\` (register) time via \`import()\`. It must NOT trigger any side effects or read environment variables.
## WorkflowFn
Async generator from \`createWorkflow(definition, binding)\` (**@uncaged/workflow-runtime**) — yields each role output until the workflow completes.
The **ModeratorTable** on **WorkflowDefinition** is declarative routing (from each role and \`START\` to the next role or \`END\`); the engine evaluates conditions at runtime.
## ModeratorTable
Declarative routing table. Transitions use the \`role\` field (not \`next\`):
\`\`\`typescript
import { START, END, type ModeratorTable } from "@uncaged/workflow-runtime";
const table: ModeratorTable<MyMeta> = {
[START]: [{ condition: "FALLBACK", role: "firstRole" }],
firstRole: [{ condition: "FALLBACK", role: END }],
};
\`\`\`
## AdapterFn / AdapterBinding
The adapter receives a system prompt and Zod schema, returns a \`RoleFn<T>\` that produces typed meta:
\`\`\`typescript
type AdapterFn = <T>(prompt: string, schema: ZodType<T>) => RoleFn<T>;
type AdapterBinding = {
adapter: AdapterFn;
overrides: Partial<Record<string, AdapterFn>> | null;
};
\`\`\`
## Role Definition
@@ -230,15 +258,16 @@ Each role has:
# 1. Initialize a workspace
uncaged-workflow init workspace my-workflow
# 2. Write your template (roles + ModeratorTable + descriptor)
# 2. Write your template (roles + ModeratorTable + definition)
# 3. Write entry file (workflows/*-entry.ts) with adapter binding + descriptor
# 3. Build the ESM bundle
bun run build
# 4. Build the ESM bundle
bun run bundle # uses scripts/bundle.ts
# 4. Register locally
uncaged-workflow workflow add my-workflow ./dist/my-workflow.esm.js
# 5. Register locally
uncaged-workflow workflow add my-workflow ./dist/my-workflow-entry.esm.js
# 5. Test
# 6. Test
uncaged-workflow run my-workflow --prompt "test task"
uncaged-workflow live --latest
\`\`\`
@@ -246,5 +275,46 @@ uncaged-workflow live --latest
## Versioning
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions.
## Pitfalls
### Lazy initialization is mandatory
The bundle is \`import()\`-ed at register time (\`workflow add\`) to read the descriptor. At that point, no runtime env vars (API keys, etc.) are available.
**Never read env at module top-level.** Wrap provider/adapter creation in a lazy closure:
\`\`\`typescript
// ❌ WRONG — breaks register
const provider = { apiKey: process.env.MY_KEY! };
const adapter = createAdapter(provider);
// ✅ CORRECT — only reads env when run() is called
function createLazyAdapter(): AdapterFn {
let cached: Provider | null = null;
return (prompt, schema) => {
return async (ctx, runtime) => {
if (!cached) cached = { apiKey: process.env.MY_KEY! };
// ... use cached provider
};
};
}
\`\`\`
### 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.
### No default exports
The engine only reads named exports \`run\` and \`descriptor\`. Using \`export default\` will cause registration to fail silently.
### Single-file ESM
The bundle must be a single \`.esm.js\` file. No dynamic \`import()\` inside the bundle — it breaks hash verification and the loader sandbox.
`;
}
@@ -79,7 +79,7 @@ describe("validateCursorAgentConfig", () => {
});
describe("createCursorAgent", () => {
test("returns an AgentFn with explicit workspace", () => {
test("returns an AdapterFn with explicit workspace", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
@@ -90,7 +90,7 @@ describe("createCursorAgent", () => {
expect(typeof agent).toBe("function");
});
test("returns an AgentFn with null workspace and llmProvider", () => {
test("returns an AdapterFn with null workspace and llmProvider", () => {
const agent = createCursorAgent({
command: "/usr/local/bin/cursor-agent",
model: null,
+17 -7
View File
@@ -1,18 +1,28 @@
{
"name": "@uncaged/workflow-agent-cursor",
"version": "0.3.11",
"version": "0.4.0",
"files": [
"dist",
"package.json"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-reactor": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-reactor": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^",
"zod": "^4.0.0"
},
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}
+14 -7
View File
@@ -1,6 +1,11 @@
import type { AgentFn } from "@uncaged/workflow-runtime";
import type { AdapterFn } from "@uncaged/workflow-runtime";
import { createLogger } from "@uncaged/workflow-util";
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import {
buildThreadInput,
createTextAdapter,
type SpawnCliError,
spawnCli,
} from "@uncaged/workflow-util-agent";
import { extractWorkspacePath } from "./extract-workspace.js";
import type { CursorAgentConfig } from "./types.js";
@@ -29,12 +34,12 @@ function resolveCursorModel(model: string | null): string {
}
/** Runs `cursor-agent` with workspace from config or extracted from context via LLM. */
export function createCursorAgent(config: CursorAgentConfig): AgentFn {
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
const modelFlag = resolveCursorModel(config.model);
const timeoutMs = config.timeout > 0 ? config.timeout : null;
const logger = createLogger({ sink: { kind: "stderr" } });
return async (ctx) => {
return createTextAdapter(async (ctx, prompt) => {
const validated = validateCursorAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
@@ -48,7 +53,8 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
if (config.llmProvider === null) {
throw new Error("cursor-agent: llmProvider is required when workspace is null");
}
const extracted = await extractWorkspacePath(ctx, config.llmProvider, logger);
const agentCtx = { ...ctx, currentRole: { name: "cursor", systemPrompt: prompt } };
const extracted = await extractWorkspacePath(agentCtx, config.llmProvider, logger);
if (extracted === null) {
throw new Error(
"cursor-agent: failed to extract workspace path from context. Provide an explicit workspace or ensure previous steps include a repoPath.",
@@ -58,7 +64,8 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
}
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
const fullPrompt = await buildAgentPrompt(ctx);
const threadInput = await buildThreadInput(ctx);
const fullPrompt = `${prompt}\n\n${threadInput}`;
const args = [
"-p",
fullPrompt,
@@ -79,5 +86,5 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
throwCursorSpawnError(run.error);
}
return run.value;
};
});
}
@@ -37,7 +37,7 @@ describe("validateHermesAgentConfig", () => {
});
describe("createHermesAgent", () => {
test("returns an AgentFn even with invalid config (validation deferred to call)", () => {
test("returns an AdapterFn even with invalid config (validation deferred to call)", () => {
const agent = createHermesAgent({
command: "/usr/local/bin/hermes",
model: null,
+14 -4
View File
@@ -1,14 +1,24 @@
{
"name": "@uncaged/workflow-agent-hermes",
"version": "0.3.11",
"version": "0.4.0",
"files": [
"dist",
"package.json"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*"
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
},
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}
+12 -6
View File
@@ -1,5 +1,10 @@
import type { AgentFn } from "@uncaged/workflow-runtime";
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import type { AdapterFn } from "@uncaged/workflow-runtime";
import {
buildThreadInput,
createTextAdapter,
type SpawnCliError,
spawnCli,
} from "@uncaged/workflow-util-agent";
import type { HermesAgentConfig } from "./types.js";
import { validateHermesAgentConfig } from "./validate-config.js";
@@ -25,16 +30,17 @@ function throwHermesSpawnError(error: SpawnCliError): never {
}
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
export function createHermesAgent(config: HermesAgentConfig): AgentFn {
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
const timeoutMs = config.timeout;
return async (ctx) => {
return createTextAdapter(async (ctx, prompt) => {
const validated = validateHermesAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
const fullPrompt = await buildAgentPrompt(ctx);
const threadInput = await buildThreadInput(ctx);
const fullPrompt = `${prompt}\n\n${threadInput}`;
const args = [
"chat",
"-q",
@@ -55,5 +61,5 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn {
throwHermesSpawnError(run.error);
}
return run.value;
};
});
}
@@ -1,9 +1,16 @@
import { describe, expect, test } from "bun:test";
import { type AgentContext, START } from "@uncaged/workflow-runtime";
import {
type CasStore,
type ExtractFn,
START,
type ThreadContext,
type WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import * as z from "zod";
import { createLlmAdapter } from "../src/create-llm-adapter.js";
function makeCtx(userContent: string): AgentContext {
function makeCtx(userContent: string): ThreadContext {
return {
start: {
role: START,
@@ -16,14 +23,34 @@ function makeCtx(userContent: string): AgentContext {
bundleHash: "TESTHASH00001",
steps: [],
threadId: "01TEST000000000000000000TR",
currentRole: { name: "planner", systemPrompt: "system instructions" },
};
}
const testSchema = z.object({ summary: z.string() });
function makeRuntime(): WorkflowRuntime {
let stored = "";
const cas: CasStore = {
put: async (content: string) => {
stored = content;
return "HASH001";
},
get: async () => stored,
delete: async () => {},
list: async () => [],
};
const extract: ExtractFn = async (_schema, _contentHash) => ({
meta: { summary: "extracted" },
contentPayload: stored,
refs: [],
});
return { cas, extract };
}
describe("createLlmAdapter", () => {
const originalFetch = globalThis.fetch;
test("posts system + user (start.content) and returns assistant text", async () => {
test("posts system + user (start.content) and returns typed meta with childThread: null", async () => {
globalThis.fetch = (() =>
Promise.resolve(
new Response(JSON.stringify({ choices: [{ message: { content: "model reply" } }] }), {
@@ -34,11 +61,13 @@ describe("createLlmAdapter", () => {
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
const out = await adapter(makeCtx("trigger text"));
const roleFn = adapter("system instructions", testSchema);
const result = await roleFn(makeCtx("trigger text"), makeRuntime());
globalThis.fetch = originalFetch;
expect(out).toBe("model reply");
expect(result.meta).toEqual({ summary: "extracted" });
expect(result.childThread).toBeNull();
});
test("throws on non-ok fetch response", async () => {
@@ -52,8 +81,9 @@ describe("createLlmAdapter", () => {
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
const roleFn = adapter("system", testSchema);
await expect(adapter(makeCtx("hi"))).rejects.toThrow("llm:");
await expect(roleFn(makeCtx("hi"), makeRuntime())).rejects.toThrow("llm:");
globalThis.fetch = originalFetch;
});
@@ -62,8 +92,9 @@ describe("createLlmAdapter", () => {
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
const roleFn = adapter("system", testSchema);
await expect(adapter(makeCtx("hi"))).rejects.toThrow();
await expect(roleFn(makeCtx("hi"), makeRuntime())).rejects.toThrow();
globalThis.fetch = originalFetch;
});
});
+17 -3
View File
@@ -1,13 +1,27 @@
{
"name": "@uncaged/workflow-agent-llm",
"version": "0.3.11",
"version": "0.4.0",
"files": [
"dist",
"package.json"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:*"
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
},
"devDependencies": {
"zod": "^4.0.0"
},
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}
@@ -1,11 +1,5 @@
import {
type AgentContext,
type AgentFn,
err,
type LlmProvider,
ok,
type Result,
} from "@uncaged/workflow-runtime";
import { type AdapterFn, err, type LlmProvider, ok, type Result } from "@uncaged/workflow-runtime";
import { createTextAdapter } from "@uncaged/workflow-util-agent";
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
@@ -97,13 +91,13 @@ export async function chatCompletionText(options: {
return parseAssistantText(res.value);
}
/** Single-turn chat adapter: system prompt comes from {@link AgentContext.currentRole}. */
export function createLlmAdapter(provider: LlmProvider): AgentFn {
return async (ctx: AgentContext) => {
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
return createTextAdapter(async (ctx, prompt) => {
const result = await chatCompletionText({
provider,
messages: [
{ role: "system", content: ctx.currentRole.systemPrompt },
{ role: "system", content: prompt },
{ role: "user", content: ctx.start.content },
],
});
@@ -111,5 +105,5 @@ export function createLlmAdapter(provider: LlmProvider): AgentFn {
throw new Error(`llm: ${formatLlmChatError(result.error)}`);
}
return result.value;
};
});
}
+1 -1
View File
@@ -6,5 +6,5 @@
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow-runtime" }]
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
}
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test";
import { ok, START, type ThreadContext, type WorkflowRuntime } from "@uncaged/workflow-protocol";
import type { LlmFn, ToolDefinition } from "@uncaged/workflow-reactor";
import { ok } from "@uncaged/workflow-protocol";
import { START, type ThreadContext, type WorkflowRuntime } from "@uncaged/workflow-protocol";
import * as z from "zod/v4";
import { createReactAdapter } from "../src/create-react-adapter.js";
@@ -39,10 +38,12 @@ const STUB_RUNTIME: WorkflowRuntime = {
}),
};
const TEST_SCHEMA = z.object({
summary: z.string(),
score: z.number(),
}).meta({ title: "resolve", description: "Submit the final result." });
const TEST_SCHEMA = z
.object({
summary: z.string(),
score: z.number(),
})
.meta({ title: "resolve", description: "Submit the final result." });
function makeChatResponse(content: string | null, toolCalls: unknown[] | null): string {
const message: Record<string, unknown> = { role: "assistant" };
@@ -156,7 +157,9 @@ describe("createReactAdapter", () => {
callCount += 1;
if (callCount === 1) {
// Invalid: score should be number, not string
return ok(makeToolCallResponse("resolve", { summary: "bad", score: "not-a-number" }, "call_1"));
return ok(
makeToolCallResponse("resolve", { summary: "bad", score: "not-a-number" }, "call_1"),
);
}
return ok(makeToolCallResponse("resolve", { summary: "fixed", score: 10 }, "call_2"));
};
@@ -1,9 +1,9 @@
import { describe, test, expect, afterAll } from "bun:test";
import { readFileTool, writeFileTool, patchFileTool, shellExecTool } from "../src/tools/index.js";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { readFileSync, unlinkSync, mkdirSync } from "node:fs";
import { afterAll, describe, expect, test } from "bun:test";
import { randomBytes } from "node:crypto";
import { mkdirSync, readFileSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { patchFileTool, readFileTool, shellExecTool, writeFileTool } from "../src/tools/index.js";
const TMP_DIR = join(tmpdir(), `tools-test-${randomBytes(4).toString("hex")}`);
mkdirSync(TMP_DIR, { recursive: true });
@@ -14,9 +14,17 @@ const cleanupFiles: string[] = [];
afterAll(() => {
for (const f of cleanupFiles) {
try { unlinkSync(f); } catch { /* ignore */ }
try {
unlinkSync(f);
} catch {
/* ignore */
}
}
try {
unlinkSync(TMP_DIR);
} catch {
/* ignore */
}
try { unlinkSync(TMP_DIR); } catch { /* ignore */ }
});
describe("read_file", () => {
@@ -26,7 +34,9 @@ describe("read_file", () => {
const content = "line1\nline2\nline3\n";
require("node:fs").writeFileSync(p, content);
const result = await readFileTool.handler(JSON.stringify({ path: p, offset: null, limit: null }));
const result = await readFileTool.handler(
JSON.stringify({ path: p, offset: null, limit: null }),
);
expect(result).toContain("1|line1");
expect(result).toContain("2|line2");
expect(result).toContain("3|line3");
@@ -42,7 +52,9 @@ describe("read_file", () => {
});
test("returns error for missing file", async () => {
const result = await readFileTool.handler(JSON.stringify({ path: "/nonexistent/file.txt", offset: null, limit: null }));
const result = await readFileTool.handler(
JSON.stringify({ path: "/nonexistent/file.txt", offset: null, limit: null }),
);
expect(result).toContain("Error:");
});
});
@@ -64,7 +76,9 @@ describe("patch_file", () => {
cleanupFiles.push(p);
require("node:fs").writeFileSync(p, "foo bar baz");
const result = await patchFileTool.handler(JSON.stringify({ path: p, old_string: "bar", new_string: "qux" }));
const result = await patchFileTool.handler(
JSON.stringify({ path: p, old_string: "bar", new_string: "qux" }),
);
expect(result).toContain("Successfully");
expect(readFileSync(p, "utf-8")).toBe("foo qux baz");
});
@@ -74,7 +88,9 @@ describe("patch_file", () => {
cleanupFiles.push(p);
require("node:fs").writeFileSync(p, "foo");
const result = await patchFileTool.handler(JSON.stringify({ path: p, old_string: "xyz", new_string: "abc" }));
const result = await patchFileTool.handler(
JSON.stringify({ path: p, old_string: "xyz", new_string: "abc" }),
);
expect(result).toContain("not found");
});
@@ -83,14 +99,18 @@ describe("patch_file", () => {
cleanupFiles.push(p);
require("node:fs").writeFileSync(p, "aaa bbb aaa");
const result = await patchFileTool.handler(JSON.stringify({ path: p, old_string: "aaa", new_string: "ccc" }));
const result = await patchFileTool.handler(
JSON.stringify({ path: p, old_string: "aaa", new_string: "ccc" }),
);
expect(result).toContain("not unique");
});
});
describe("shell_exec", () => {
test("runs echo", async () => {
const result = await shellExecTool.handler(JSON.stringify({ command: "echo hello", timeout: null }));
const result = await shellExecTool.handler(
JSON.stringify({ command: "echo hello", timeout: null }),
);
expect(result.trim()).toBe("hello");
});
+11 -7
View File
@@ -1,22 +1,26 @@
{
"name": "@uncaged/workflow-agent-react",
"version": "0.3.11",
"version": "0.4.0",
"files": [
"dist",
"package.json"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-reactor": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*"
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-reactor": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
},
"devDependencies": {
"zod": "^4.0.0"
+2 -2
View File
@@ -1,4 +1,4 @@
export { createReactAdapter } from "./create-react-adapter.js";
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
export { defaultTools, defaultToolHandler } from "./tools/index.js";
export type { ToolEntry, ToolHandler } from "./tools/index.js";
export { defaultToolHandler, defaultTools } from "./tools/index.js";
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
@@ -1,9 +1,9 @@
import type { ToolDefinition } from "@uncaged/workflow-reactor";
import type { ToolEntry } from "./types.js";
import { readFileTool } from "./read-file.js";
import { writeFileTool } from "./write-file.js";
import { patchFileTool } from "./patch-file.js";
import { readFileTool } from "./read-file.js";
import { shellExecTool } from "./shell-exec.js";
import type { ToolEntry } from "./types.js";
import { writeFileTool } from "./write-file.js";
const ALL_TOOLS: ToolEntry[] = [readFileTool, writeFileTool, patchFileTool, shellExecTool];
@@ -1,6 +1,6 @@
export { readFileTool } from "./read-file.js";
export { writeFileTool } from "./write-file.js";
export { defaultToolHandler, defaultTools } from "./defaults.js";
export { patchFileTool } from "./patch-file.js";
export { readFileTool } from "./read-file.js";
export { shellExecTool } from "./shell-exec.js";
export { defaultTools, defaultToolHandler } from "./defaults.js";
export type { ToolEntry, ToolHandler } from "./types.js";
export { writeFileTool } from "./write-file.js";
@@ -30,7 +30,10 @@ export const patchFileTool: ToolEntry = {
if (secondIdx !== -1) {
return `Error: old_string is not unique in ${parsed.path} (found multiple occurrences)`;
}
const updated = content.slice(0, firstIdx) + parsed.new_string + content.slice(firstIdx + parsed.old_string.length);
const updated =
content.slice(0, firstIdx) +
parsed.new_string +
content.slice(firstIdx + parsed.old_string.length);
await writeFile(parsed.path, updated);
return `Successfully patched ${parsed.path}`;
} catch (err) {
@@ -11,7 +11,10 @@ export const readFileTool: ToolEntry = {
type: "object",
properties: {
path: { type: "string", description: "Path to the file to read" },
offset: { type: ["number", "null"], description: "Start line number (1-indexed, default: 1)" },
offset: {
type: ["number", "null"],
description: "Start line number (1-indexed, default: 1)",
},
limit: { type: ["number", "null"], description: "Max lines to read (default: all)" },
},
required: ["path"],
@@ -20,12 +23,17 @@ export const readFileTool: ToolEntry = {
},
handler: async (args: string): Promise<string> => {
try {
const parsed = JSON.parse(args) as { path: string; offset: number | null; limit: number | null };
const parsed = JSON.parse(args) as {
path: string;
offset: number | null;
limit: number | null;
};
const content = await readFile(parsed.path, "utf-8");
const allLines = content.split("\n");
const offset = parsed.offset ?? 1;
const start = Math.max(0, offset - 1);
const end = parsed.limit != null ? Math.min(allLines.length, start + parsed.limit) : allLines.length;
const end =
parsed.limit != null ? Math.min(allLines.length, start + parsed.limit) : allLines.length;
const lines = allLines.slice(start, end);
return lines.map((line, i) => `${start + i + 1}|${line}`).join("\n");
} catch (err) {
@@ -3,6 +3,27 @@ import type { ToolEntry } from "./types.js";
const MAX_OUTPUT = 10000;
function truncate(text: string): string {
return text.length > MAX_OUTPUT ? `${text.slice(0, MAX_OUTPUT)}\n...(truncated)` : text;
}
function classifyExecError(err: unknown): string {
if (
err &&
typeof err === "object" &&
"status" in err &&
(err as { status: unknown }).status === null
) {
return "Error: command timed out";
}
if (err && typeof err === "object" && "stderr" in err) {
const e = err as { stderr: string; stdout: string; status: number };
const combined = `${e.stdout ?? ""}${e.stderr ?? ""}`;
return truncate(combined) || `Error: command exited with status ${e.status}`;
}
return `Error: ${err instanceof Error ? err.message : String(err)}`;
}
export const shellExecTool: ToolEntry = {
definition: {
type: "function",
@@ -29,17 +50,9 @@ export const shellExecTool: ToolEntry = {
stdio: ["pipe", "pipe", "pipe"],
maxBuffer: MAX_OUTPUT * 2,
});
return output.length > MAX_OUTPUT ? `${output.slice(0, MAX_OUTPUT)}\n...(truncated)` : output;
return truncate(output);
} catch (err: unknown) {
if (err && typeof err === "object" && "status" in err && (err as { status: unknown }).status === null) {
return "Error: command timed out";
}
if (err && typeof err === "object" && "stderr" in err) {
const e = err as { stderr: string; stdout: string; status: number };
const combined = `${e.stdout ?? ""}${e.stderr ?? ""}`;
return combined.length > MAX_OUTPUT ? `${combined.slice(0, MAX_OUTPUT)}\n...(truncated)` : combined || `Error: command exited with status ${e.status}`;
}
return `Error: ${err instanceof Error ? err.message : String(err)}`;
return classifyExecError(err);
}
},
};
+9 -4
View File
@@ -1,19 +1,24 @@
{
"name": "@uncaged/workflow-cas",
"version": "0.3.11",
"version": "0.4.0",
"files": [
"dist",
"package.json"
],
"type": "module",
"scripts": {
"test": "bun test"
},
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
"import": "./dist/index.js"
}
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-util": "workspace:*",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"xxhashjs": "^0.2.2",
"yaml": "^2.7.1"
},
+4 -1
View File
@@ -1,6 +1,10 @@
{
"name": "@uncaged/workflow-dashboard",
"version": "0.1.0",
"files": [
"dist",
"package.json"
],
"private": true,
"type": "module",
"scripts": {
@@ -9,7 +13,6 @@
"preview": "vite preview"
},
"dependencies": {
"@dagrejs/dagre": "^3.0.0",
"@xyflow/react": "^12.10.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
@@ -70,7 +70,6 @@ export function LoginPage({ onLogin }: Props) {
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
autoFocus
/>
{error && (
<p className="text-xs mb-3" style={{ color: "var(--color-error)" }}>
@@ -2,11 +2,46 @@ import {
BaseEdge,
EdgeLabelRenderer,
type EdgeProps,
getBezierPath,
getSmoothStepPath,
} from "@xyflow/react";
import type { ConditionEdgeData } from "./types.ts";
// Must match the FEEDBACK_OFFSET_X in use-layout.ts
const FEEDBACK_OFFSET_X = 100;
// Radius for feedback edge corners
const FEEDBACK_RADIUS = 16;
/**
* Build an SVG path for a feedback (back) edge that routes to the right of the nodes.
* The path goes: source right → arc → vertical up → arc → target right
*/
function feedbackPath(
sourceX: number,
sourceY: number,
targetX: number,
targetY: number,
): string {
const rightX = Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X;
const r = FEEDBACK_RADIUS;
// Start from source right side, go right, then up, then left to target right side
const segments = [
`M ${sourceX} ${sourceY}`,
// Horizontal to the right
`L ${rightX - r} ${sourceY}`,
// Arc turning upward
`Q ${rightX} ${sourceY} ${rightX} ${sourceY - r}`,
// Vertical upward
`L ${rightX} ${targetY + r}`,
// Arc turning left
`Q ${rightX} ${targetY} ${rightX - r} ${targetY}`,
// Horizontal left to target
`L ${targetX} ${targetY}`,
];
return segments.join(" ");
}
export function ConditionEdge(props: EdgeProps) {
const {
id,
@@ -24,28 +59,41 @@ export function ConditionEdge(props: EdgeProps) {
const edgeData = data as ConditionEdgeData | undefined;
const isFallback = edgeData?.isFallback ?? false;
const isSelfLoop = source === target;
const isFeedback = edgeData?.isFeedback ?? false;
const [path, labelX, labelY] = isSelfLoop
? getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: 20,
})
: getBezierPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
});
let path: string;
let defaultLabelX: number;
let defaultLabelY: number;
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-text)";
if (isFeedback) {
// Custom feedback path routed to the right
path = feedbackPath(sourceX, sourceY, targetX, targetY);
const rightX = Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X;
defaultLabelX = rightX;
defaultLabelY = (sourceY + targetY) / 2;
} else {
const result = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: isSelfLoop ? 20 : 8,
offset: isSelfLoop ? 50 : undefined,
});
path = result[0];
defaultLabelX = result[1];
defaultLabelY = result[2];
}
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-accent)";
const strokeDasharray = isFallback ? "5 4" : undefined;
const label = edgeData?.condition ?? "";
// Use pre-computed label position if available, otherwise fall back to default
const labelX = edgeData?.labelX ?? defaultLabelX;
const labelY = edgeData?.labelY ?? defaultLabelY;
return (
<>
@@ -55,19 +103,21 @@ export function ConditionEdge(props: EdgeProps) {
markerEnd={markerEnd}
style={{ stroke, strokeWidth: 1.5, strokeDasharray }}
/>
{edgeData && !isFallback && edgeData.condition !== "" && (
{label !== "" && (
<EdgeLabelRenderer>
<div
className="absolute px-1.5 py-0.5 rounded text-[10px] font-mono pointer-events-auto"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
background: "var(--color-bg)",
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text)",
color: isFallback ? "var(--color-text-muted)" : "var(--color-text)",
whiteSpace: "nowrap",
zIndex: 10,
}}
title={edgeData.conditionDescription ?? undefined}
title={edgeData?.conditionDescription ?? undefined}
>
{edgeData.condition}
{label}
</div>
</EdgeLabelRenderer>
)}
@@ -21,6 +21,10 @@ export type ConditionEdgeData = {
condition: string;
conditionDescription: string | null;
isFallback: boolean;
isFeedback: boolean;
isSelfLoop: boolean;
labelX: number | null;
labelY: number | null;
[key: string]: unknown;
};
@@ -1,4 +1,3 @@
import Dagre from "@dagrejs/dagre";
import type { Edge, Node } from "@xyflow/react";
import { useMemo } from "react";
import type { WorkflowGraphEdge } from "../../api.ts";
@@ -10,6 +9,11 @@ const ROLE_NODE_WIDTH = 180;
const ROLE_NODE_HEIGHT = 60;
const TERMINAL_NODE_SIZE = 40;
// Vertical gap between nodes in the spine
const LAYER_GAP = 80;
// Horizontal offset for feedback (back) edges routed on the right side
const FEEDBACK_OFFSET_X = 100;
type LayoutInput = {
edges: readonly WorkflowGraphEdge[];
roles: Record<string, { description: string }>;
@@ -21,15 +25,6 @@ type LayoutResult = {
edges: Edge[];
};
function collectNodeIds(edges: readonly WorkflowGraphEdge[]): Set<string> {
const ids = new Set<string>();
for (const e of edges) {
ids.add(e.from);
ids.add(e.to);
}
return ids;
}
function nodeSize(id: string): { width: number; height: number } {
if (id === START_ID || id === END_ID) {
return { width: TERMINAL_NODE_SIZE, height: TERMINAL_NODE_SIZE };
@@ -37,6 +32,79 @@ function nodeSize(id: string): { width: number; height: number } {
return { width: ROLE_NODE_WIDTH, height: ROLE_NODE_HEIGHT };
}
function edgeKey(e: WorkflowGraphEdge): string {
return `${e.from}->${e.to}::${e.condition}`;
}
/**
* Extract the linear spine from the graph using topological ordering.
* Forward edges go from lower rank to higher rank; feedback edges go backwards.
* Self-loops are neither forward nor feedback — they're handled separately.
*/
function extractSpine(edges: readonly WorkflowGraphEdge[]): string[] {
// Collect all node IDs
const ids = new Set<string>();
for (const e of edges) {
ids.add(e.from);
ids.add(e.to);
}
// Build adjacency for forward edges only (non-self-loop, non-FALLBACK-back)
// Strategy: BFS from __start__, picking the first non-FALLBACK forward edge,
// or FALLBACK if no other option.
const forwardAdj = new Map<string, string[]>();
for (const e of edges) {
if (e.from === e.to) continue;
const existing = forwardAdj.get(e.from) ?? [];
existing.push(e.to);
forwardAdj.set(e.from, existing);
}
// Walk the main path: prefer non-FALLBACK edges for the spine ordering
const visited = new Set<string>();
const spine: string[] = [];
// Build a set of "primary" next targets per node (non-FALLBACK first)
const primaryNext = new Map<string, string>();
const edgesByFrom = new Map<string, WorkflowGraphEdge[]>();
for (const e of edges) {
if (e.from === e.to) continue;
const list = edgesByFrom.get(e.from) ?? [];
list.push(e);
edgesByFrom.set(e.from, list);
}
// For each node, the "primary" next is the first non-FALLBACK target,
// or the FALLBACK target if all edges are FALLBACK
for (const [from, edgeList] of edgesByFrom) {
const nonFallback = edgeList.find((e) => e.condition !== "FALLBACK");
const fallback = edgeList.find((e) => e.condition === "FALLBACK");
primaryNext.set(from, nonFallback?.to ?? fallback?.to ?? "");
}
// Walk the spine from __start__
let current: string | null = START_ID;
while (current !== null && !visited.has(current)) {
visited.add(current);
spine.push(current);
const next = primaryNext.get(current);
if (next !== undefined && next !== "" && !visited.has(next)) {
current = next;
} else {
current = null;
}
}
// Add any remaining nodes not on the main path (shouldn't normally happen)
for (const id of ids) {
if (!visited.has(id)) {
spine.push(id);
}
}
return spine;
}
function buildRoleNode(
id: string,
pos: { x: number; y: number },
@@ -68,60 +136,95 @@ function buildTerminalNode(
};
}
function edgeKey(e: WorkflowGraphEdge): string {
return `${e.from}->${e.to}::${e.condition}`;
}
function computeLayout(input: LayoutInput): LayoutResult {
const spine = extractSpine(input.edges);
const rank = new Map<string, number>();
for (let i = 0; i < spine.length; i++) {
rank.set(spine[i], i);
}
function buildEdge(e: WorkflowGraphEdge): Edge<ConditionEdgeData> {
const isFallback = e.condition === "FALLBACK";
return {
id: edgeKey(e),
source: e.from,
target: e.to,
type: "condition",
data: {
condition: e.condition,
conditionDescription: e.conditionDescription,
isFallback,
},
};
// Position nodes along a vertical spine, centered horizontally
const centerX = ROLE_NODE_WIDTH / 2; // left edge at x=0, center at width/2
const nodePositions = new Map<string, { x: number; y: number; w: number; h: number }>();
let y = 0;
for (const id of spine) {
const size = nodeSize(id);
// Center-align all nodes on the spine
const x = centerX - size.width / 2;
nodePositions.set(id, { x, y, w: size.width, h: size.height });
y += size.height + LAYER_GAP;
}
// Build nodes
const nodes: Node[] = [];
for (const id of spine) {
const pos = nodePositions.get(id);
if (pos === undefined) continue;
const state = input.nodeStates.get(id) ?? "default";
if (id === START_ID || id === END_ID) {
nodes.push(buildTerminalNode(id, { x: pos.x, y: pos.y }, state));
} else {
nodes.push(buildRoleNode(id, { x: pos.x, y: pos.y }, input.roles, state));
}
}
// Build edges with label positions
// For feedback edges (target rank < source rank), we'll compute label at midpoint
// of the right-side arc. The actual SVG path is drawn by ConditionEdge component.
const edges: Edge[] = input.edges.map((e) => {
const isFallback = e.condition === "FALLBACK";
const isSelfLoop = e.from === e.to;
const sourceRank = rank.get(e.from) ?? 0;
const targetRank = rank.get(e.to) ?? 0;
const isFeedback = !isSelfLoop && targetRank <= sourceRank;
const sourcePos = nodePositions.get(e.from);
const targetPos = nodePositions.get(e.to);
let labelX: number | null = null;
let labelY: number | null = null;
if (sourcePos !== undefined && targetPos !== undefined) {
if (isFeedback) {
// Label on the right side of the feedback arc
const rightX = centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X;
const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2;
labelX = rightX;
labelY = midY;
} else if (!isSelfLoop) {
// Forward edge: label between source bottom and target top
const midX = centerX;
const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2;
labelX = midX;
labelY = midY;
}
// Self-loop: let ReactFlow default handle it
}
return {
id: edgeKey(e),
source: e.from,
target: e.to,
type: "condition",
data: {
condition: e.condition,
conditionDescription: e.conditionDescription,
isFallback,
isFeedback,
isSelfLoop,
labelX,
labelY,
},
};
});
return { nodes, edges };
}
export function useLayout(input: LayoutInput): LayoutResult {
return useMemo(() => {
const ids = collectNodeIds(input.edges);
const g = new Dagre.graphlib.Graph({ multigraph: true }).setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
for (const id of ids) {
const size = nodeSize(id);
g.setNode(id, { width: size.width, height: size.height });
}
for (const e of input.edges) {
if (e.from === e.to) {
continue;
}
g.setEdge(e.from, e.to, {}, edgeKey(e));
}
Dagre.layout(g);
const nodes: Node[] = [];
for (const id of ids) {
const dagNode = g.node(id);
const size = nodeSize(id);
const pos = { x: dagNode.x - size.width / 2, y: dagNode.y - size.height / 2 };
const state = input.nodeStates.get(id) ?? "default";
if (id === START_ID || id === END_ID) {
nodes.push(buildTerminalNode(id, pos, state));
} else {
nodes.push(buildRoleNode(id, pos, input.roles, state));
}
}
const edges: Edge[] = input.edges.map(buildEdge);
return { nodes, edges };
}, [input.edges, input.roles, input.nodeStates]);
return useMemo(
() => computeLayout(input),
[input.edges, input.roles, input.nodeStates],
);
}
@@ -66,6 +66,8 @@ export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props)
onNodeClick={onNodeClickHandler}
fitView
fitViewOptions={{ padding: 0.15 }}
minZoom={0.3}
maxZoom={2}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
@@ -45,38 +45,45 @@ function ExpandedWorkflowBody({
const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
const vc = versionCount(detail);
const hasGraph = descriptor !== null && edgeCount > 0;
return (
<div className="pt-3 space-y-3 border-t" style={{ borderColor: "var(--color-border)" }}>
<div>
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
{detail.name}
<div
className="pt-3 border-t flex gap-4"
style={{ borderColor: "var(--color-border)" }}
>
<div className="space-y-3 shrink-0" style={{ minWidth: 200, maxWidth: 280 }}>
<div>
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
{detail.name}
</p>
<p className="text-xs mt-1 mb-1" style={{ color: "var(--color-text-muted)" }}>
Hash
</p>
<code className="text-xs font-mono block" style={{ color: "var(--color-accent)" }}>
{detail.hash}
</code>
</div>
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{vc} version{vc !== 1 ? "s" : ""}
</p>
<p className="text-xs mt-1 mb-1" style={{ color: "var(--color-text-muted)" }}>
Hash
</p>
<code className="text-xs font-mono block" style={{ color: "var(--color-accent)" }}>
{detail.hash}
</code>
<div>
<p className="text-xs mb-1 font-medium" style={{ color: "var(--color-text-muted)" }}>
Description
</p>
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--color-text)" }}>
{descriptor !== null && descriptor.description !== ""
? descriptor.description
: descriptor !== null
? "—"
: "No descriptor available for this workflow version."}
</p>
</div>
</div>
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{vc} version{vc !== 1 ? "s" : ""}
</p>
<div>
<p className="text-xs mb-1 font-medium" style={{ color: "var(--color-text-muted)" }}>
Description
</p>
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--color-text)" }}>
{descriptor !== null && descriptor.description !== ""
? descriptor.description
: descriptor !== null
? "—"
: "No descriptor available for this workflow version."}
</p>
</div>
{descriptor !== null && edgeCount > 0 ? (
{hasGraph ? (
<div
className="rounded-lg border overflow-hidden"
style={{ borderColor: "var(--color-border)", background: "var(--color-bg)" }}
className="rounded-lg border overflow-hidden flex-1"
style={{ borderColor: "var(--color-border)", background: "var(--color-bg)", minHeight: 500 }}
>
<div
className="px-3 py-2 text-xs flex justify-between items-center"
@@ -87,7 +94,7 @@ function ExpandedWorkflowBody({
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
</span>
</div>
<div style={{ height: 300, width: "100%" }}>
<div style={{ height: 600, width: "100%" }}>
<WorkflowGraph
graph={descriptor.graph}
roles={descriptor.roles}
@@ -148,18 +155,17 @@ export function WorkflowList({ agent }: Props) {
);
function toggleExpanded(name: string) {
let shouldLoad = false;
const wasExpanded = expanded.has(name);
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
return next;
} else {
next.add(name);
}
next.add(name);
shouldLoad = true;
return next;
});
if (shouldLoad) {
if (!wasExpanded) {
ensureDetailLoaded(name);
}
}
@@ -14,7 +14,7 @@ import type {
} from "@uncaged/workflow-runtime";
import { executeThread } from "../src/engine/engine.js";
import type { ExecuteThreadIo, ExecuteThreadOptions } from "../src/engine/types.js";
import type { ExecuteThreadOptions } from "../src/engine/types.js";
const TEST_REGISTRY_YAML = `config:
maxDepth: 3
+13 -8
View File
@@ -1,23 +1,28 @@
{
"name": "@uncaged/workflow-execute",
"version": "0.3.11",
"version": "0.4.0",
"files": [
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util": "workspace:*",
"@uncaged/workflow-cas": "workspace:*",
"@uncaged/workflow-reactor": "workspace:*",
"@uncaged/workflow-register": "workspace:*",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-cas": "workspace:^",
"@uncaged/workflow-reactor": "workspace:^",
"@uncaged/workflow-register": "workspace:^",
"yaml": "^2.7.1"
},
"peerDependencies": {
+31 -1
View File
@@ -299,7 +299,37 @@ async function driveWorkflowGenerator(params: {
});
}
const iterResult = await gen.next();
const iterResult = await Promise.race([
gen.next(),
new Promise<never>((_, reject) => {
if (executeOptions.signal.aborted) {
reject(new DOMException("The operation was aborted", "AbortError"));
return;
}
executeOptions.signal.addEventListener(
"abort",
() => reject(new DOMException("The operation was aborted", "AbortError")),
{ once: true },
);
}),
]).catch((e) => {
if (e instanceof DOMException && e.name === "AbortError") {
return { done: true as const, value: { returnCode: 130, summary: "thread aborted" } };
}
throw e;
});
if (executeOptions.signal.aborted || (iterResult.done && iterResult.value.returnCode === 130)) {
return await finalizeAbortedThread({
cas,
bundleDir,
threadId,
startHash,
chain,
logger,
abortLogTag: "H4KQ7RW3",
});
}
if (iterResult.done) {
logger("F3HN8QKP", `thread ${threadId} generator finished`);
+3
View File
@@ -42,4 +42,7 @@ export {
llmErrorToCause,
llmExtract,
} 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";
@@ -0,0 +1,165 @@
import { join } from "node:path";
import { createCasStore, putContentNodeWithRefs } from "@uncaged/workflow-cas";
import type { WorkflowConfig } from "@uncaged/workflow-register";
import {
extractBundleExports,
getRegisteredWorkflow,
readWorkflowRegistry,
} from "@uncaged/workflow-register";
import type {
AdapterFn,
RoleResult,
ThreadContext,
WorkflowFn,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import {
createLogger,
generateUlid,
getDefaultWorkflowStorageRoot,
getGlobalCasDir,
} from "@uncaged/workflow-util";
import type * as z from "zod/v4";
import type { ExecuteThreadIo } from "./engine/index.js";
import { executeThread, getBundleDir, readThreadsIndex } from "./engine/index.js";
const DEFAULT_WORKFLOW_ADAPTER_MAX_DEPTH = 3;
function workflowAdapterMaxDepth(config: WorkflowConfig | null): number {
return config === null ? DEFAULT_WORKFLOW_ADAPTER_MAX_DEPTH : config.maxDepth;
}
export type WorkflowAdapterOptions = {
/** When `null`, uses `getDefaultWorkflowStorageRoot()`. */
storageRoot: string | null;
};
function resolveStorageRoot(options: WorkflowAdapterOptions | null): string {
if (options !== null && options.storageRoot !== null) {
return options.storageRoot;
}
return getDefaultWorkflowStorageRoot();
}
async function readParentHeadState(
storageRoot: string,
ctx: ThreadContext,
): Promise<string | null> {
const bundleDir = getBundleDir(storageRoot, ctx.bundleHash);
const index = await readThreadsIndex(bundleDir);
const entry = index[ctx.threadId] ?? null;
return entry !== null ? entry.head : null;
}
/** Resolve the workflow bundle and validate depth limits. */
async function resolveWorkflowBundle(workflowName: string, storageRoot: string, nextDepth: number) {
const registryResult = await readWorkflowRegistry(storageRoot);
if (!registryResult.ok) {
throw new Error(`failed to read workflow registry: ${registryResult.error.message}`);
}
const maxDepth = workflowAdapterMaxDepth(registryResult.value.config);
if (nextDepth > maxDepth) {
throw new Error(`workflow adapter depth limit exceeded (max ${maxDepth})`);
}
const entry = getRegisteredWorkflow(registryResult.value, workflowName);
if (entry === null) {
throw new Error(`workflow "${workflowName}" not found in registry`);
}
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
const bundleExportsResult = await extractBundleExports(bundlePath, { storageRoot });
if (!bundleExportsResult.ok) {
throw new Error(String(bundleExportsResult.error));
}
return { entry, run: bundleExportsResult.value.run };
}
/** Execute the child workflow thread and return a summary + root hash. */
async function runChildThread(params: {
workflowName: string;
storageRoot: string;
ctx: ThreadContext;
run: WorkflowFn;
bundleHash: string;
nextDepth: number;
}) {
const { workflowName, storageRoot, ctx, run, bundleHash, nextDepth } = params;
const childThreadId = generateUlid(Date.now());
const infoJsonlPath = join(storageRoot, "logs", bundleHash, `${childThreadId}.info.jsonl`);
const io: ExecuteThreadIo = {
threadId: childThreadId,
hash: bundleHash,
infoJsonlPath,
cas: createCasStore(getGlobalCasDir(storageRoot)),
};
const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } });
const parentHeadState = await readParentHeadState(storageRoot, ctx);
const result = await executeThread(
run,
workflowName,
{ prompt: ctx.start.content, steps: [] },
{
depth: nextDepth,
parentStateHash: parentHeadState,
signal: new AbortController().signal,
awaitAfterEachYield: async () => {},
forkSourceThreadId: ctx.threadId,
prefilledDiskSteps: null,
forkContinuation: null,
replayTimestamps: null,
storageRoot,
},
io,
logger,
);
return {
summary: `Child workflow "${workflowName}" completed (returnCode=${result.returnCode}).\n\nSummary: ${result.summary}\n\nChild thread root hash: ${result.rootHash}`,
rootHash: result.rootHash,
};
}
/**
* Returns an {@link AdapterFn} that runs another registered workflow in a new child thread,
* using the parent thread's initial prompt (`ctx.start.content`) as the child prompt.
*
* The child thread's root hash is returned as `childThread` in the result,
* enabling parent→child tracking in the CAS Merkle tree.
*/
export function workflowAdapter(
workflowName: string,
options: WorkflowAdapterOptions | null = null,
): AdapterFn {
return <T>(_prompt: string, schema: z.ZodType<T>) => {
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const storageRoot = resolveStorageRoot(options);
const { entry, run } = await resolveWorkflowBundle(workflowName, storageRoot, ctx.depth + 1);
try {
const { summary, rootHash } = await runChildThread({
workflowName,
storageRoot,
ctx,
run,
bundleHash: entry.hash,
nextDepth: ctx.depth + 1,
});
const contentHash = await putContentNodeWithRefs(runtime.cas, summary, []);
const extracted = await runtime.extract(
schema as z.ZodType<Record<string, unknown>>,
contentHash,
);
return { meta: extracted.meta as T, childThread: rootHash };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
throw new Error(`child workflow "${workflowName}" failed: ${message}`);
}
};
};
}
@@ -1,127 +1,8 @@
import { join } from "node:path";
import { createCasStore } from "@uncaged/workflow-cas";
import type { WorkflowConfig } from "@uncaged/workflow-register";
import {
extractBundleExports,
getRegisteredWorkflow,
readWorkflowRegistry,
} from "@uncaged/workflow-register";
import type { AgentContext, AgentFn, AgentFnResult } from "@uncaged/workflow-runtime";
import {
createLogger,
generateUlid,
getDefaultWorkflowStorageRoot,
getGlobalCasDir,
} from "@uncaged/workflow-util";
import type { ExecuteThreadIo } from "./engine/index.js";
import { executeThread, getBundleDir, readThreadsIndex } from "./engine/index.js";
const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
function workflowAsAgentMaxDepth(config: WorkflowConfig | null): number {
if (config === null) {
return DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH;
}
return config.maxDepth;
}
export type WorkflowAsAgentOptions = {
/** When `null`, uses `getDefaultWorkflowStorageRoot()`. */
storageRoot: string | null;
};
function resolveWorkflowAsAgentStorageRoot(options: WorkflowAsAgentOptions | null): string {
if (options !== null && options.storageRoot !== null) {
return options.storageRoot;
}
return getDefaultWorkflowStorageRoot();
}
async function readParentHeadState(storageRoot: string, ctx: AgentContext): Promise<string | null> {
const bundleDir = getBundleDir(storageRoot, ctx.bundleHash);
const index = await readThreadsIndex(bundleDir);
const entry = index[ctx.threadId] ?? null;
return entry !== null ? entry.head : null;
}
/**
* Returns an {@link AgentFn} that runs another registered workflow in a new thread,
* using the parent thread's initial prompt (`ctx.start.content`) as the child prompt.
* @deprecated Use `workflowAdapter` from `./workflow-adapter.js` instead.
* This module is kept for backward compatibility and will be removed in a future release.
*/
export function workflowAsAgent(
workflowName: string,
options: WorkflowAsAgentOptions | null = null,
): AgentFn {
return async (ctx: AgentContext): Promise<AgentFnResult> => {
const nextDepth = ctx.depth + 1;
const storageRoot = resolveWorkflowAsAgentStorageRoot(options);
const registryResult = await readWorkflowRegistry(storageRoot);
if (!registryResult.ok) {
return { output: `ERROR: failed to read workflow registry: ${registryResult.error.message}`, childThread: null };
}
const maxDepth = workflowAsAgentMaxDepth(registryResult.value.config);
if (nextDepth > maxDepth) {
return { output: `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`, childThread: null };
}
const entry = getRegisteredWorkflow(registryResult.value, workflowName);
if (entry === null) {
return { output: `ERROR: workflow "${workflowName}" not found in registry`, childThread: null };
}
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
const bundleExportsResult = await extractBundleExports(bundlePath, { storageRoot });
if (!bundleExportsResult.ok) {
return { output: `ERROR: ${bundleExportsResult.error}`, childThread: null };
}
const input = {
prompt: ctx.start.content,
steps: [],
};
const childThreadId = generateUlid(Date.now());
const infoJsonlPath = join(storageRoot, "logs", entry.hash, `${childThreadId}.info.jsonl`);
const io: ExecuteThreadIo = {
threadId: childThreadId,
hash: entry.hash,
infoJsonlPath,
cas: createCasStore(getGlobalCasDir(storageRoot)),
};
const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } });
const signalNever = new AbortController();
const parentHeadState = await readParentHeadState(storageRoot, ctx);
try {
const result = await executeThread(
bundleExportsResult.value.run,
workflowName,
input,
{
depth: nextDepth,
parentStateHash: parentHeadState,
signal: signalNever.signal,
awaitAfterEachYield: async () => {},
forkSourceThreadId: ctx.threadId,
prefilledDiskSteps: null,
forkContinuation: null,
replayTimestamps: null,
storageRoot,
},
io,
logger,
);
const summary = `Child workflow "${workflowName}" completed (returnCode=${result.returnCode}).\n\nSummary: ${result.summary}\n\nChild thread root hash: ${result.rootHash}`;
return { output: summary, childThread: result.rootHash };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { output: `ERROR: ${message}`, childThread: null };
}
};
}
export {
type WorkflowAdapterOptions as WorkflowAsAgentOptions,
workflowAdapter as workflowAsAgent,
} from "./workflow-adapter.js";
+5 -1
View File
@@ -1,6 +1,10 @@
{
"name": "@uncaged/workflow-gateway",
"version": "0.3.11",
"version": "0.4.0",
"files": [
"dist",
"package.json"
],
"type": "module",
"exports": {
".": "./src/index.ts",
+9 -3
View File
@@ -1,15 +1,21 @@
{
"name": "@uncaged/workflow-protocol",
"version": "0.3.11",
"version": "0.4.0",
"files": [
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
"import": "./dist/index.js"
},
"./moderator-table.js": {
"bun": "./src/moderator-table.ts",
"types": "./dist/moderator-table.d.ts",
"import": "./src/moderator-table.ts"
"import": "./dist/moderator-table.js"
}
},
"peerDependencies": {
+1 -4
View File
@@ -12,10 +12,7 @@ export type {
AdapterBinding,
AdapterFn,
AdvanceOutcome,
AgentBinding,
AgentContext,
AgentFn,
AgentFnResult,
CasStore,
ExtractFn,
ExtractResult,
@@ -30,9 +27,9 @@ export type {
Result,
RoleDefinition,
RoleFn,
RoleResult,
RoleMeta,
RoleOutput,
RoleResult,
RoleStep,
StartStep,
ThreadContext,
-12
View File
@@ -143,18 +143,6 @@ export type ExtractFn = <T extends Record<string, unknown>>(
contentHash: string,
) => Promise<ExtractResult<T>>;
/** @deprecated Use {@link AdapterFn} instead. Will be removed in a future release. */
export type AgentFnResult = string | { output: string; childThread: string | null };
/** @deprecated Use {@link AdapterFn} instead. Will be removed in a future release. */
export type AgentFn = (ctx: AgentContext) => Promise<AgentFnResult>;
/** @deprecated Use {@link AdapterBinding} instead. Will be removed in a future release. */
export type AgentBinding = {
agent: AgentFn;
overrides: Partial<Record<string, AgentFn>> | null;
};
// ── Adapter (replaces Agent) ────────────────────────────────────────
export type RoleResult<T> = { meta: T; childThread: string | null };
+8 -3
View File
@@ -1,15 +1,20 @@
{
"name": "@uncaged/workflow-reactor",
"version": "0.3.11",
"version": "0.4.0",
"files": [
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
"import": "./dist/index.js"
}
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*"
"@uncaged/workflow-protocol": "workspace:^"
},
"peerDependencies": {
"zod": "^4.0.0"
+9 -4
View File
@@ -1,16 +1,21 @@
{
"name": "@uncaged/workflow-register",
"version": "0.3.11",
"version": "0.4.0",
"files": [
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
"import": "./dist/index.js"
}
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*",
"@uncaged/workflow-util": "workspace:*"
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^"
},
"peerDependencies": {
"acorn": "^8.0.0",
+14 -4
View File
@@ -1,20 +1,30 @@
{
"name": "@uncaged/workflow-runtime",
"version": "0.3.11",
"version": "0.4.0",
"files": [
"dist",
"package.json"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-cas": "workspace:*",
"@uncaged/workflow-protocol": "workspace:*"
"@uncaged/workflow-cas": "workspace:^",
"@uncaged/workflow-protocol": "workspace:^"
},
"peerDependencies": {
"zod": "^4.0.0"
},
"devDependencies": {
"zod": "^4.0.0"
},
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}
@@ -37,7 +37,7 @@ function resolveExtractedRefs(
return extractRefsFn(meta as Record<string, unknown>);
}
function mergeUniqueHashes(a: readonly string[], b: readonly string[]): string[] {
function _mergeUniqueHashes(a: readonly string[], b: readonly string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const h of [...a, ...b]) {
@@ -83,7 +83,10 @@ async function advanceOneRound<M extends RoleMeta>(
}
const adapter = adapterForRole(binding, next);
const roleFn = adapter(roleDef.systemPrompt, roleDef.schema as z.ZodType<Record<string, unknown>>);
const roleFn = adapter(
roleDef.systemPrompt,
roleDef.schema as z.ZodType<Record<string, unknown>>,
);
const result = await roleFn(modCtx as unknown as ThreadContext, runtime);
const meta = result.meta;
+1 -4
View File
@@ -4,10 +4,7 @@ export { err, ok } from "./result.js";
export type {
AdapterBinding,
AdapterFn,
AgentBinding,
AgentContext,
AgentFn,
AgentFnResult,
CasStore,
ExtractFn,
ExtractResult,
@@ -20,9 +17,9 @@ export type {
Result,
RoleDefinition,
RoleFn,
RoleResult,
RoleMeta,
RoleOutput,
RoleResult,
RoleStep,
StartStep,
ThreadContext,
+1 -4
View File
@@ -6,10 +6,7 @@ export type {
AdapterBinding,
AdapterFn,
AdvanceOutcome,
AgentBinding,
AgentContext,
AgentFn,
AgentFnResult,
CasStore,
ExtractFn,
ExtractResult,
@@ -24,9 +21,9 @@ export type {
Result,
RoleDefinition,
RoleFn,
RoleResult,
RoleMeta,
RoleOutput,
RoleResult,
RoleStep,
StartStep,
ThreadContext,
@@ -5,34 +5,20 @@
*/
import { createCursorAgent } from "@uncaged/workflow-agent-cursor";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { wrapAgentAsAdapter } from "@uncaged/workflow-util-agent";
import { optionalEnv, requireEnv } from "@uncaged/workflow-util";
import { buildDevelopDescriptor, developWorkflowDefinition } from "./src/index.js";
function requireEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value === "") {
throw new Error(`missing required env var: ${name}`);
}
return value;
}
function optionalEnv(name: string): string | null {
const value = process.env[name];
if (value === undefined || value === "") {
return null;
}
return value;
}
const llmProvider = {
baseUrl:
optionalEnv("WORKFLOW_LLM_BASE_URL") ?? "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: requireEnv("WORKFLOW_LLM_API_KEY"),
model: optionalEnv("WORKFLOW_LLM_MODEL") ?? "qwen-plus",
baseUrl: optionalEnv(
"WORKFLOW_LLM_BASE_URL",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
),
apiKey: requireEnv("WORKFLOW_LLM_API_KEY", "set WORKFLOW_LLM_API_KEY for meta extraction"),
model: optionalEnv("WORKFLOW_LLM_MODEL", "qwen-plus"),
};
const agent = createCursorAgent({
command: requireEnv("WORKFLOW_CURSOR_COMMAND"),
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"))
@@ -41,8 +27,6 @@ const agent = createCursorAgent({
llmProvider,
});
const adapter = wrapAgentAsAdapter(agent);
const wf = createWorkflow(developWorkflowDefinition, { adapter, overrides: null });
export const descriptor = buildDevelopDescriptor();
@@ -1,22 +1,27 @@
{
"name": "@uncaged/workflow-template-develop",
"version": "0.3.11",
"version": "0.4.0",
"files": [
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-register": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-register": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"zod": "^4.0.0"
},
"devDependencies": {
"@uncaged/workflow-protocol": "workspace:*"
"@uncaged/workflow-protocol": "workspace:^"
}
}
@@ -215,10 +215,7 @@ describe("solveIssueWorkflowDefinition + createWorkflow", () => {
});
const gen = run(makeThread("task"), {
cas,
extract: createExtract(
{ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" },
{ cas },
),
extract: createExtract({ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" }, { cas }),
});
const first = await gen.next();
expect(first.done).toBe(false);
@@ -261,10 +258,7 @@ describe("solveIssueWorkflowDefinition + createWorkflow", () => {
});
const gen = run(makeThread("task"), {
cas,
extract: createExtract(
{ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" },
{ cas },
),
extract: createExtract({ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test" }, { cas }),
});
await gen.next();
expect(calls).toEqual(["preparer"]);
@@ -2,38 +2,25 @@
* solve-issue bundle entry — 小橘 🍊
*
* preparer + submitter → hermes agent
* developer → workflow-as-agent (delegates to "develop" workflow)
* developer → workflow adapter (delegates to "develop" workflow)
*/
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
import { workflowAsAgent } from "@uncaged/workflow-execute";
import { workflowAdapter } from "@uncaged/workflow-execute";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { wrapAgentAsAdapter } from "@uncaged/workflow-util-agent";
import { optionalEnv } from "@uncaged/workflow-util";
import { buildSolveIssueDescriptor, solveIssueWorkflowDefinition } from "./src/index.js";
function optionalEnv(name: string): string | null {
const value = process.env[name];
if (value === undefined || value === "") {
return null;
}
return value;
}
const hermesAgent = createHermesAgent({
const adapter = createHermesAgent({
model: optionalEnv("WORKFLOW_HERMES_MODEL"),
timeout: optionalEnv("WORKFLOW_HERMES_TIMEOUT")
? Number(optionalEnv("WORKFLOW_HERMES_TIMEOUT"))
: null,
});
const developerAgent = workflowAsAgent("develop");
const adapter = wrapAgentAsAdapter(hermesAgent);
const developerAdapter = wrapAgentAsAdapter(developerAgent);
const wf = createWorkflow(solveIssueWorkflowDefinition, {
adapter,
overrides: {
developer: developerAdapter,
developer: workflowAdapter("develop"),
},
});
@@ -1,24 +1,29 @@
{
"name": "@uncaged/workflow-template-solve-issue",
"version": "0.3.11",
"version": "0.4.0",
"files": [
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-register": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-register": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"zod": "^4.0.0"
},
"devDependencies": {
"@uncaged/workflow-cas": "workspace:*",
"@uncaged/workflow-execute": "workspace:*",
"@uncaged/workflow-protocol": "workspace:*"
"@uncaged/workflow-cas": "workspace:^",
"@uncaged/workflow-execute": "workspace:^",
"@uncaged/workflow-protocol": "workspace:^"
}
}
+11 -6
View File
@@ -1,20 +1,25 @@
{
"name": "@uncaged/workflow-util-agent",
"version": "0.3.11",
"version": "0.4.0",
"files": [
"dist",
"package.json"
],
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-cas": "workspace:*"
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-cas": "workspace:^",
"zod": "^4.0.0"
}
}
@@ -0,0 +1,51 @@
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
import type {
AdapterFn,
RoleResult,
ThreadContext,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
/**
* Result from a text-producing agent (CLI spawn, LLM call, etc.).
* `output` is the raw text; `childThread` links to a spawned sub-workflow.
*/
export type TextAdapterResult = {
output: string;
childThread: string | null;
};
/**
* A function that produces raw text output given the thread context and
* the system prompt for the current role.
*/
export type TextProducerFn = (
ctx: ThreadContext,
prompt: string,
) => Promise<string | TextAdapterResult>;
/**
* Creates an {@link AdapterFn} from a text-producing function.
*
* The adapter:
* 1. Calls the producer with thread context + system prompt
* 2. Stores output in CAS
* 3. Runs the extract phase to produce typed meta
* 4. Returns `{ meta, childThread }`
*/
export function createTextAdapter(producer: TextProducerFn): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const result = await producer(ctx, prompt);
const output = typeof result === "string" ? result : result.output;
const childThread = typeof result === "string" ? null : result.childThread;
const contentHash = await putContentNodeWithRefs(runtime.cas, output, []);
const extracted = await runtime.extract(
schema as z.ZodType<Record<string, unknown>>,
contentHash,
);
return { meta: extracted.meta as T, childThread };
};
};
}
+2 -1
View File
@@ -1,4 +1,5 @@
export { buildAgentPrompt, buildThreadInput } from "./build-agent-prompt.js";
export type { TextAdapterResult, TextProducerFn } from "./create-text-adapter.js";
export { createTextAdapter } from "./create-text-adapter.js";
export type { SpawnCliConfig, SpawnCliError, SpawnCliResult } from "./spawn-cli.js";
export { spawnCli } from "./spawn-cli.js";
export { wrapAgentAsAdapter } from "./wrap-agent-as-adapter.js";
@@ -1,31 +0,0 @@
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
import type {
AdapterFn,
AgentContext,
AgentFnResult,
RoleResult,
ThreadContext,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
/**
* Wraps a legacy AgentFn into an AdapterFn.
* The agent produces a string (or { output, childThread }); the adapter
* stores the output in CAS, runs extract, and returns typed meta + childThread.
*/
export function wrapAgentAsAdapter(
agentFn: (ctx: AgentContext) => Promise<AgentFnResult>,
): AdapterFn {
return <T>(prompt: string, schema: z.ZodType<T>) => {
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const agentCtx: AgentContext = { ...ctx, currentRole: { name: "agent", systemPrompt: prompt } };
const result = await agentFn(agentCtx);
const output = typeof result === "string" ? result : result.output;
const childThread = typeof result === "string" ? null : result.childThread;
const contentHash = await putContentNodeWithRefs(runtime.cas, output, []);
const extracted = await runtime.extract(schema as z.ZodType<Record<string, unknown>>, contentHash);
return { meta: extracted.meta as T, childThread };
};
};
}
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test";
import { optionalEnv, requireEnv } 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;
});
test("throws with message when missing", () => {
expect(() => requireEnv("TEST_MISSING_XYZ", "need this")).toThrow("need this");
});
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;
});
});
+8 -3
View File
@@ -1,15 +1,20 @@
{
"name": "@uncaged/workflow-util",
"version": "0.3.11",
"version": "0.4.0",
"files": [
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./src/index.ts"
"import": "./dist/index.js"
}
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:*"
"@uncaged/workflow-protocol": "workspace:^"
},
"devDependencies": {
"typescript": "^5.8.3"
+23
View File
@@ -0,0 +1,23 @@
/**
* Read a required environment variable. Throws with `message` if missing or empty.
*/
export function requireEnv(name: string, message: 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 value;
}
+1
View File
@@ -6,6 +6,7 @@ export {
encodeCrockfordBase32Bits,
encodeUint64AsCrockford,
} from "./base32.js";
export { optionalEnv, requireEnv } from "./env.js";
export { createLogger } from "./logger.js";
export { mergeRefsWithContentHash, normalizeRefsField } from "./refs-field.js";
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
-127
View File
@@ -1,127 +0,0 @@
#!/usr/bin/env bash
# Publish all public @uncaged/* packages to Gitea npm registry.
#
# Usage:
# ./scripts/publish-all.sh # Publish all packages
# ./scripts/publish-all.sh --dry-run # Show what would be published
#
# Package order is auto-resolved via topological sort of workspace:* dependencies.
#
# Prerequisites:
# - .npmrc in monorepo root with Gitea auth token
# - bun (for packing with workspace:* resolution)
# - npm (for publishing tarballs)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
MONOREPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
REGISTRY="https://git.shazhou.work/api/packages/shazhou/npm/"
DRY_RUN=""
if [[ "${1:-}" == "--dry-run" ]]; then
DRY_RUN="--dry-run"
echo "🔍 Dry run mode — no packages will be published"
echo
fi
# Topological sort: read all package.json files, build dependency graph, emit leaf-first order
ORDERED=$(python3 -c "
import json, os, sys
from pathlib import Path
pkgs_dir = Path('$MONOREPO_ROOT/packages')
# name -> dir_name, and dependency edges
name_to_dir = {}
deps_graph = {} # name -> set of @uncaged/* dependency names
for d in sorted(pkgs_dir.iterdir()):
pj = d / 'package.json'
if not pj.exists():
continue
data = json.loads(pj.read_text())
name = data.get('name', '')
if not name.startswith('@uncaged/'):
continue
if data.get('private'):
continue
name_to_dir[name] = d.name
local_deps = set()
for section in ('dependencies', 'devDependencies', 'peerDependencies'):
for dep, ver in data.get(section, {}).items():
if dep.startswith('@uncaged/') and dep in name_to_dir or ver == 'workspace:*':
local_deps.add(dep)
deps_graph[name] = local_deps
# Kahn's algorithm
in_degree = {n: 0 for n in deps_graph}
for n, ds in deps_graph.items():
for d in ds:
if d in in_degree:
in_degree[d] = in_degree.get(d, 0) # ensure exists
# Recount
in_degree = {n: 0 for n in deps_graph}
for n, ds in deps_graph.items():
for d in ds:
if d in in_degree:
in_degree[d] += 1
# Wait, direction is wrong. If A depends on B, B must be published first.
# So edge is: A -> B means B must come before A.
# in_degree[A] = number of deps A has (that are in our set)
in_degree = {n: 0 for n in deps_graph}
for n, ds in deps_graph.items():
for d in ds:
if d in in_degree:
pass # d is a dependency of n
in_degree[n] = len([d for d in ds if d in deps_graph])
queue = [n for n, deg in in_degree.items() if deg == 0]
queue.sort() # stable order
result = []
while queue:
node = queue.pop(0)
result.append(node)
for n, ds in deps_graph.items():
if node in ds:
in_degree[n] -= 1
if in_degree[n] == 0:
queue.append(n)
queue.sort()
for name in result:
print(name_to_dir[name])
")
ok=0
fail=0
while IFS= read -r pkg; do
dir="$MONOREPO_ROOT/packages/$pkg"
name=$(grep -m1 '"name"' "$dir/package.json" | sed 's/.*: *"\(.*\)".*/\1/')
cd "$dir"
# bun pm pack resolves workspace:* → actual versions
tgz=$(bun pm pack 2>&1 | grep '\.tgz' | grep -v packed | head -1 | tr -d ' ')
if [[ -z "$tgz" || ! -f "$tgz" ]]; then
echo "$name — pack failed"
((fail++)) || true
continue
fi
if npm publish "$tgz" --registry="$REGISTRY" $DRY_RUN 2>&1 | tail -1 | grep -q '+'; then
echo "$name"
((ok++)) || true
else
echo "⚠️ $name (may already exist at this version)"
fi
rm -f "$tgz"
done <<< "$ORDERED"
echo
echo "Published: $ok Skipped/Failed: $fail"
+105 -79
View File
@@ -1,20 +1,31 @@
#!/usr/bin/env bash
# publish.sh — Bump version & publish all @uncaged/workflow-* packages
# publish.sh — Bump version, build, test, topologically publish @uncaged/* to Gitea npm
#
# Usage:
# ./scripts/publish.sh 0.4.0 # explicit version
# ./scripts/publish.sh patch # 0.3.1 → 0.3.2
# ./scripts/publish.sh minor # 0.3.1 → 0.4.0
# ./scripts/publish.sh 0.4.0 # explicit version
# ./scripts/publish.sh patch # 0.3.1 → 0.3.2
# ./scripts/publish.sh minor # 0.3.1 → 0.4.0
# ./scripts/publish.sh major # 0.3.1 → 1.0.0
# ./scripts/publish.sh --dry-run patch # dry-run bun publish only (no git commit/push)
#
# Env (via `cfg` or export):
# GITEA_TOKEN — Gitea npm registry auth
# GITEA_TOKEN — Gitea npm registry auth (see root .npmrc)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
GITEA_TOKEN="${GITEA_TOKEN:?GITEA_TOKEN is required}"
GITEA_NPM_REGISTRY="https://git.shazhou.work/api/packages/uncaged/npm/"
REGISTRY="https://git.shazhou.work/api/packages/uncaged/npm/"
DRY_RUN=""
if [[ "${1:-}" == "--dry-run" ]]; then
DRY_RUN="--dry-run"
shift
echo "🔍 Dry run — bun publish will not upload; git commit/push skipped"
echo
fi
# ─── Version ─────────────────────────────────────────────────────────────────
current_version() {
@@ -33,28 +44,10 @@ bump_version() {
}
CURRENT=$(current_version)
VERSION=$(bump_version "$CURRENT" "${1:?Usage: publish.sh <version|patch|minor|major>}")
VERSION=$(bump_version "$CURRENT" "${1:?Usage: publish.sh [--dry-run] <version|patch|minor|major>}")
echo "📦 Publish: $CURRENT$VERSION"
# ─── Topological publish order ───────────────────────────────────────────────
PUBLISH_ORDER=(
workflow-protocol
workflow-util
workflow-cas
workflow-runtime
workflow-reactor
workflow-register
workflow-execute
cli-workflow
workflow-util-agent
workflow-agent-cursor
workflow-agent-hermes
workflow-agent-llm
workflow-template-develop
workflow-template-solve-issue
)
# ─── Bump version ────────────────────────────────────────────────────────────
# ─── Bump version ─────────────────────────────────────────────────────────────
echo "🔢 Bumping versions..."
for dir in packages/*/; do
pkg="$dir/package.json"
@@ -69,66 +62,99 @@ for dir in packages/*/; do
"
done
# ─── Replace workspace:* ─────────────────────────────────────────────────────
echo "🔗 Replacing workspace:* → $VERSION..."
for dir in packages/*/; do
pkg="$dir/package.json"
[[ -f "$pkg" ]] || continue
node -e "
const fs = require('fs');
const p = JSON.parse(fs.readFileSync('$pkg','utf8'));
let c = false;
for (const k of ['dependencies','peerDependencies','devDependencies']) {
if (!p[k]) continue;
for (const [n, v] of Object.entries(p[k])) {
if (n.startsWith('@uncaged/') && v === 'workspace:*') { p[k][n] = '$VERSION'; c = true; }
}
}
if (c) fs.writeFileSync('$pkg', JSON.stringify(p, null, 2) + '\n');
"
done
# ─── Topological publish order (workspace:* deps first) ───────────────────────
ORDERED=$(python3 -c "
import json, sys
from pathlib import Path
# ─── Build ───────────────────────────────────────────────────────────────────
pkgs_dir = Path('$REPO_ROOT/packages')
name_to_dir = {}
for d in sorted(pkgs_dir.iterdir()):
pj = d / 'package.json'
if not pj.exists():
continue
data = json.loads(pj.read_text())
name = data.get('name', '')
if not name.startswith('@uncaged/') or data.get('private'):
continue
name_to_dir[name] = d.name
deps_graph = {}
for name, dirname in name_to_dir.items():
pj = pkgs_dir / dirname / 'package.json'
data = json.loads(pj.read_text())
local_deps = set()
for section in ('dependencies', 'devDependencies', 'peerDependencies'):
for dep, ver in data.get(section, {}).items():
if dep.startswith('@uncaged/') and dep in name_to_dir and ver.startswith('workspace:'):
local_deps.add(dep)
deps_graph[name] = local_deps
in_degree = {n: 0 for n in deps_graph}
for n, ds in deps_graph.items():
in_degree[n] = len(ds)
queue = sorted([n for n, deg in in_degree.items() if deg == 0])
result = []
while queue:
node = queue.pop(0)
result.append(node)
for n, ds in deps_graph.items():
if node in ds:
in_degree[n] -= 1
if in_degree[n] == 0:
queue.append(n)
queue.sort()
if len(result) != len(deps_graph):
missing = set(deps_graph) - set(result)
sys.stderr.write('publish: cyclic @uncaged/ workspace:* dependencies among: ' + ', '.join(sorted(missing)) + '\n')
sys.exit(1)
for name in result:
print(name_to_dir[name])
")
# ─── Build ────────────────────────────────────────────────────────────────────
echo "🔨 Building..."
npm run build
bun run build
# ─── Publish ─────────────────────────────────────────────────────────────────
echo "🚀 Publishing..."
cat > "$REPO_ROOT/.npmrc" <<EOF
@uncaged:registry=${GITEA_NPM_REGISTRY}
//${GITEA_NPM_REGISTRY#https://}:_authToken=${GITEA_TOKEN}
EOF
# ─── Self-test ────────────────────────────────────────────────────────────────
echo "🧪 Running tests..."
if ! bun test; then
echo "❌ Tests failed — aborting publish"
exit 1
fi
FAIL=0
for pkg_dir in "${PUBLISH_ORDER[@]}"; do
if (cd "packages/$pkg_dir" && npm publish 2>&1); then
echo " ✅ @uncaged/$pkg_dir@$VERSION"
# ─── Publish (bun resolves workspace:* for publish) ──────────────────────────
echo "🚀 Publishing to $REGISTRY ..."
ok=0
fail=0
while IFS= read -r pkg; do
[[ -n "$pkg" ]] || continue
dir="$REPO_ROOT/packages/$pkg"
name=$(node -e "console.log(require('$dir/package.json').name)")
if ( cd "$dir" && bun publish --registry="$REGISTRY" ${DRY_RUN:+"$DRY_RUN"} ); then
echo "$name"
ok=$((ok + 1))
else
echo " ❌ @uncaged/$pkg_dir"
FAIL=1
echo "⚠️ $name (publish failed or version may already exist)"
fail=$((fail + 1))
fi
done
# ─── Restore workspace:* ─────────────────────────────────────────────────────
echo "🔄 Restoring workspace:*..."
for dir in packages/*/; do
pkg="$dir/package.json"
[[ -f "$pkg" ]] || continue
node -e "
const fs = require('fs');
const p = JSON.parse(fs.readFileSync('$pkg','utf8'));
let c = false;
for (const k of ['dependencies','peerDependencies','devDependencies']) {
if (!p[k]) continue;
for (const [n, v] of Object.entries(p[k])) {
if (n.startsWith('@uncaged/') && v === '$VERSION') { p[k][n] = 'workspace:*'; c = true; }
}
}
if (c) fs.writeFileSync('$pkg', JSON.stringify(p, null, 2) + '\n');
"
done
done <<< "$ORDERED"
echo
echo "Published: $ok Skipped/Failed: $fail"
# ─── Commit ───────────────────────────────────────────────────────────────────
if [[ -n "$DRY_RUN" ]]; then
echo "⏭️ Skipping git commit/push (dry run). Revert bumps with: git checkout -- packages/*/package.json"
exit 0
fi
# ─── Commit ──────────────────────────────────────────────────────────────────
echo "📝 Committing..."
git add -A
git commit -m "chore: publish v${VERSION}
@@ -136,4 +162,4 @@ git commit -m "chore: publish v${VERSION}
小橘 <xiaoju@shazhou.work>"
git push
[[ "$FAIL" -eq 0 ]] && echo "✅ v${VERSION} published" || echo "⚠️ v${VERSION} published with errors"
echo "✅ v${VERSION} published"
+1 -1
View File
@@ -15,7 +15,7 @@
"sourceMap": true,
"composite": true,
"outDir": "dist",
"types": ["bun-types"]
"types": ["bun-types", "node"]
},
"references": [
{ "path": "packages/workflow-runtime" },