Compare commits

..

71 Commits

Author SHA1 Message Date
jiayiyan 5342fa95f2 docs: add office-agent document template spec and implementation plan 2026-05-18 20:17:26 +08:00
jiayiyan 6cbd2e306e feat(agent): add workflow-agent-office runner with generate/edit and tests 2026-05-18 20:17:26 +08:00
jiayiyan 08c5476f28 docs(architecture): add workflow-agent-office, workflow-agent-docx-diff, workflow-template-document
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:17:26 +08:00
jiayiyan 684c2e8be4 feat(agent): add workflow-agent-docx-diff with docx-diff AdapterFn
Implements createDocxDiffAgent (AdapterFn), packageDescriptor, and exports in index.ts; 9 tests pass (runner 6 + agent 3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:17:26 +08:00
jiayiyan 58aabb7bf5 feat(agent): scaffold workflow-agent-docx-diff package
Add package.json, tsconfig.json, and placeholder src/index.ts for
@uncaged/workflow-agent-docx-diff; append reference in root tsconfig.json.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:17:26 +08:00
jiayiyan 7bbe5b62d4 feat(agent): add workflow-agent-office with generate/edit AdapterFn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:17:26 +08:00
jiayiyan 9e351de294 feat(agent): scaffold workflow-agent-office package
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:17:26 +08:00
jiayiyan df748982e6 feat(template): add workflow-template-document with writer/differ roles and moderator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:17:26 +08:00
jiayiyan d93d299677 chore(template): scaffold workflow-template-document package
Add package.json, tsconfig.json, and placeholder src/index.ts for the
@uncaged/workflow-template-document package; register it in root tsconfig.json references.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 20:17:26 +08:00
xiaoju 701f320e23 feat: Phase 7 — CLI json-cas commands
- json-cas init/workflow register+show/thread show/node get+list+walk
- Nested subcommand dispatch under 'json-cas' group
- Default store: ~/.uncaged/workflow/json-cas/
- 15 tests passing, biome clean

Closes #304
小橘 <xiaoju@shazhou.work>
2026-05-18 20:17:04 +08:00
xingyue 68ec2884d6 chore: remove pnpm-lock.yaml + enforce bun-only installs
- Delete accidentally committed pnpm-lock.yaml
- Add pnpm-lock.yaml, package-lock.json, yarn.lock to .gitignore
- Add packageManager field (bun@1.3.13)
- Add preinstall script that errors when not using bun
2026-05-18 20:17:04 +08:00
xiaoju a2f24e1eb5 chore: switch json-cas deps from file: to npm ^0.1.0
小橘 <xiaoju@shazhou.work>
2026-05-18 20:17:04 +08:00
xiaoju 1e9f4d650d feat: Phase 5 — React layer instrumentation
- json-cas-react-recorder.ts: writeReactSession stores full ReAct trace
- ReactTrace/ReactTurnTrace/ReactToolCallTrace types
- JsonCasAgentResult with optional react trace
- Engine integration: real react-session when trace provided, placeholder when null
- 10 new tests (29 total for json-cas engine), biome clean

Closes #301
小橘 <xiaoju@shazhou.work>
2026-05-18 20:17:04 +08:00
xiaoju 288a93dcce feat: Phase 4 — json-cas engine (new engine alongside old)
- json-cas-engine.ts: new engine using json-cas Store + typed nodes
- json-cas-context.ts: build ThreadContext from thread-step chain
- json-cas-types.ts: engine types (JsonCasEngineIo, JsonCasAgentFn, etc.)
- thread-start/step/end/content nodes in json-cas format
- JSONata moderator via evaluateModerator
- react placeholder (Phase 5 will fill in)
- 21 tests passing, biome clean

Closes #299
小橘 <xiaoju@shazhou.work>
2026-05-18 20:17:04 +08:00
xiaoju c07d4cca4d feat: Phase 3 — workflow JSON definitions in CAS
- New package: @uncaged/workflow-json-def
- registerWorkflow/loadWorkflow for CAS round-trip
- solve-issue and develop templates converted to pure JSON
- Zod schemas → JSON Schema, moderator → JSONata
- 30 tests passing, biome clean

Closes #297
小橘 <xiaoju@shazhou.work>
2026-05-18 20:17:02 +08:00
xiaoju 8ee5112c9d feat: JSONata moderator engine (Phase 2 of #294)
- evaluateModerator(rules, context) with JSONata expression evaluation
- Fallback (when: null), conditional branching, no-match → __end__
- Full develop workflow moderator ported to JSONata
- 31 tests passing

Closes #295
小橘 <xiaoju@shazhou.work>
2026-05-18 20:16:24 +08:00
xiaoju 1a397c1dc8 chore: remove _spec.md from cards (build artifact)
小橘 <xiaoju@shazhou.work>
2026-05-18 20:16:24 +08:00
xiaoju ca24e6abde docs: add .cards/ architecture documentation
12 interlinked architecture cards + index, covering:
- Core: Bundle, Thread, CAS, Registry
- Execution: Engine, Role, Agent Binding, Reactor
- Tooling: CLI, Dashboard, Package Map
- Authoring: Workflow Templates

小橘 <xiaoju@shazhou.work>
2026-05-18 20:16:24 +08:00
xiaoju 4e126a258a refactor: rename casRef to x-cas-ref for JSON Schema compliance
Rename .meta({ casRef: true }) to .meta({ 'x-cas-ref': true }) across
all schema annotations. Zod v4 toJSONSchema automatically outputs
x-cas-ref in the generated JSON Schema, so buildDescriptor preserves
the annotation without any code changes.

Add buildDescriptor test verifying x-cas-ref appears in descriptor
output for annotated fields and is absent for plain fields.

220 tests pass, 0 fail.

Fixes #291, Refs #285
2026-05-18 20:16:24 +08:00
Scott Wei 7c230383ad improve: multi-column model list + friendly post-setup message
- Model list now renders in columns to fit terminal width
- Interactive setup ends with usage hints instead of JSON dump

小橘 🍊(NEKO Team)
2026-05-18 19:56:09 +08:00
xiaoju e604fa5f47 feat: add uwf setup command
- Interactive mode: prompts for provider, API key, model (with /models discovery)
- Non-interactive mode: --provider --base-url --api-key --model flags
- Writes config.yaml (providers, models, agents, defaults)
- Writes .env (API keys with auto-generated env var names)
- Merges into existing config non-destructively
- Includes 13 preset providers (international + China + local)

小橘 🍊(NEKO Team)
2026-05-18 11:49:42 +00:00
xiaoju 5580791686 chore: remove stale develop-entry.ts 2026-05-18 11:43:09 +00:00
xiaoju 3afd7a5319 chore: remove leftover smoke test files 2026-05-18 11:41:49 +00:00
xiaoju 3d1b2268b4 chore: bump json-cas deps to ^0.1.3 2026-05-18 10:48:06 +00:00
xiaoju 8bebe9da0f chore: bump json-cas-fs to ^0.1.2 (fix workspace:^ in published pkg) 2026-05-18 10:44:30 +00:00
xiaoju 53a7355f0b chore: fix json-cas workspace:^ refs to ^0.1.1 2026-05-18 10:30:31 +00:00
xiaoju d99c285725 chore: remove cross-repo json-cas workspace deps from root 2026-05-18 10:28:22 +00:00
xiaoju 2505dd8d6a chore: remove stale pnpm-lock.yaml 2026-05-18 10:25:45 +00:00
xiaomo 1121dfa48b Merge pull request 'feat: uwf — Stateless Workflow CLI' (#317) from feat/309-uwf-stateless into main 2026-05-18 10:07:55 +00:00
xiaoju d90e29ad05 fix: address 3 critical PR review issues
1. threads.yaml race condition: reload threads index after agent subprocess
   completes before updating head pointer (cli-uwf/commands/thread.ts)

2. evaluateJsonata not awaited: jsonata evaluate() returns Promise for async
   expressions — now properly awaited (uwf-moderator/evaluate.ts)

3. resolveWorkflowHash dead code: function always returns a value, removed
   impossible null return type and dead null-check branches at call sites
   (cli-uwf/store.ts, commands/thread.ts, commands/workflow.ts)
2026-05-18 10:05:11 +00:00
xiaoju 0727e0e8d5 fix: reload CAS store after agent spawn + share schemas via uwf-protocol
The agent subprocess writes StepNode to CAS on disk, but the parent
process had an in-memory cache from createFsStore init. Fix: re-create
store after agent spawn to pick up new nodes.

Also centralized JSON Schemas in uwf-protocol so cli-uwf and agent-kit
produce identical type hashes.

E2E smoke test passing: workflow put → thread start → 3x step → done

Refs #309
2026-05-18 09:33:52 +00:00
xiaoju ba012d98bc feat: add @uncaged/uwf-agent-hermes — Hermes agent CLI adapter
Spawns 'hermes chat' with assembled prompt from agent-kit context.
Agent-kit handles extract, StepNode write, and stdout output.

Refs #309, #316
2026-05-18 09:22:12 +00:00
xiaoju b165049a13 feat: implement thread step — moderator → agent → update head
- Walk CAS chain to build ModeratorContext with expanded output
- Call uwf-moderator evaluate() for role decision
- Agent resolution: --agent > config overrides > default
- Spawn agent CLI, capture StepNode hash
- Update threads.yaml, check done via second evaluate
- Archive on $END

Refs #309, #315
2026-05-18 09:19:37 +00:00
xiaoju 4d477c67c0 feat: add @uncaged/uwf-agent-kit — agent CLI framework
- createAgent() API for building agent CLIs
- Context builder: reads CAS chain, builds AgentContext
- Extract: LLM-based structured output extraction
- StepNode writer: writes to CAS without touching threads.yaml
- Stdout: outputs StepNode hash

Refs #309, #314
2026-05-18 09:15:25 +00:00
xiaoju 0d5678c961 feat: add thread start/show/list/kill commands
- thread start: ULID generation, StartNode to CAS, threads.yaml
- thread show: active (done:false) or archived (done:true)
- thread list: active threads, --all includes history
- thread kill: archive to history.jsonl

Refs #309, #313
2026-05-18 09:09:10 +00:00
xiaoju a8e2aa85f8 feat: add @uncaged/cli-uwf with workflow put/show/list commands
Refs #309, #312
2026-05-18 09:03:55 +00:00
xiaoju 2a4d35399b feat: add @uncaged/uwf-moderator with JSONata evaluation engine
5 tests passing:  transition, condition match, fallback,
missing role error, output expansion.

Refs #309, #311
2026-05-18 08:58:21 +00:00
xiaoju 391915411e feat: add @uncaged/uwf-protocol with all shared types
Refs #309, #310
2026-05-18 08:53:37 +00:00
scottwei 4aaf49bfc6 Merge pull request 'jshang/optimize-dashboard-ui' (#308) from jshang/optimize-dashboard-ui into main
Reviewed-on: #308
2026-05-18 08:45:46 +00:00
xiaoju 08de1ae5eb docs: fresh uwf-* packages, depend on @uncaged/json-cas, no reuse 2026-05-18 08:44:04 +00:00
xiaoju c91a3d1ec6 docs: add description to condition definitions 2026-05-18 08:41:29 +00:00
xiaoju 13d932f69c docs: config with provider/model/agent registries and alias-based overrides 2026-05-18 08:38:08 +00:00
jiashuang f705d9b8ea refactor: optimize ui for dashboard 2026-05-18 16:20:05 +08:00
xiaoju f84d327410 docs: add .env for API keys, separate from config.yaml 2026-05-18 08:19:48 +00:00
xiaoju 9c2f93629b docs: add models config (default + extract LLM) 2026-05-18 08:16:03 +00:00
xiaoju bcefcb9af7 docs: add section 4 — key data types with shared StepRecord 2026-05-18 08:13:18 +00:00
xiaoju b14dce2bc6 docs: fix inconsistencies — title, terminology, threads.yaml, JSONata context 2026-05-18 08:09:40 +00:00
xiaoju 85c572e770 docs: inline roles/moderator into Workflow, output as cas_ref, detail polymorphic 2026-05-18 08:07:20 +00:00
xiaoju 9a89885ce6 docs: rewrite CAS structure — flatten refs, named conditions, config.yaml, output naming 2026-05-18 07:55:04 +00:00
xiaoju d095ceaafa docs: agent CLI takes thread-id + role, outputs CAS hash, step owns pointer 2026-05-18 07:24:14 +00:00
xiaoju 2a0346f48b docs: simplify show to pure thread-id → head query, all output JSON 2026-05-18 07:18:29 +00:00
xiaoju b4e25ea002 docs: add done field to step output 2026-05-18 07:12:38 +00:00
xiaoju 77f2060e6b docs: step on ended thread is an error, not null head 2026-05-18 07:11:50 +00:00
xiaoju 8f9a925179 docs: simplify step output to workflow/thread/head 2026-05-18 07:10:11 +00:00
jiashuang 2f3fff3536 refactor: introduce react-router 2026-05-18 15:06:16 +08:00
xiaoju a7eb9814ae docs: fix agent invocation format in thread step 2026-05-18 07:05:35 +00:00
xiaoju a8024e6d42 docs: use full 26-char Crockford Base32 ULIDs for thread IDs 2026-05-18 07:03:40 +00:00
xiaoju 6d94d9c85a docs: fix hash format to 13-char Crockford Base32 (XXH64) 2026-05-18 07:03:02 +00:00
xiaoju 49a4d08c04 docs: add thread list --all and thread kill 2026-05-18 06:59:47 +00:00
xiaoju d5773369af docs: uwf thread subcommands, simplify start output 2026-05-18 06:58:35 +00:00
xiaoju f49e014f41 docs: update CLI design — uwf naming, simplify commands and agent protocol 2026-05-18 06:56:55 +00:00
xiaoju ab48a8169d docs: add stateless workflow CLI design
Refs #297
2026-05-18 06:37:25 +00:00
xiaomo 2b707fb44e Merge pull request 'refactor: replace extractRefs with schema casRef annotations (Phase 2)' (#290) from feat/285-phase2-remove-extractrefs into main 2026-05-16 11:50:47 +00:00
xiaoju 6306b23a9f refactor: replace extractRefs with schema casRef annotations
Migrate all templates to use .meta({ casRef: true }) on Zod schema
fields instead of manual extractRefs functions. Remove extractRefs
from RoleDefinition type entirely.

- develop: planner phases[].hash, coder completedPhase annotated
- solve-issue, smoke, init templates: extractRefs removed
- create-workflow.ts: uses collectCasRefs(schema, meta)
- RoleDefinition: extractRefs field removed (breaking)

218 tests pass, 0 fail.

Fixes #289, Refs #285
2026-05-16 10:48:45 +00:00
xiaomo 6bb8cf8315 Merge pull request 'feat: add collectCasRefs — schema-level CAS ref annotation (Phase 1)' (#288) from feat/285-cas-ref-annotation into main 2026-05-16 10:43:16 +00:00
xiaoju 93b7947d7c feat: add collectCasRefs — extract CAS refs from schema meta annotations
Replaces manual extractRefs functions with declarative schema-level
casRef annotations. Walks Zod v4 schemas recursively, collecting
string values from fields marked with .meta({ casRef: true }).

Supports: objects, arrays, discriminatedUnion, nullable/optional.

8/8 test cases pass (flat, nested, union, null, mixed).

Refs #285, addresses #286
2026-05-16 10:42:24 +00:00
xingyue 9584a86fb7 Merge pull request 'chore: fix biome cognitive complexity warnings' (#287) from chore/fix-biome-complexity-warnings into main 2026-05-16 10:40:27 +00:00
Scott Wei defc0afc27 chore: fix biome cognitive complexity warnings
Refactor dashboard graph/schema helpers and descriptor role validation
into smaller functions so bun run check passes without warnings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 18:33:00 +08:00
xiaomo 9f6633d5bf Merge pull request 'refactor(workflow-protocol): require AgentFn Opt generic' (#284) from refactor/agent-fn-required-opt into main 2026-05-16 10:27:07 +00:00
Scott Wei 7dadf874e1 refactor(workflow-protocol): require AgentFn Opt generic
Make AgentFn<Opt> always take a mandatory options argument, removing
the void conditional overload. Simplify createAgentAdapter, restore
exports needed by tests, and fix CLI test bundles to use cas.put
instead of disallowed @uncaged/* imports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 18:23:07 +08:00
xiaomo ba90214af6 Merge pull request 'chore: internalize unused exports across all packages' (#283) from chore/audit-exports-cleanup into main 2026-05-16 09:59:57 +00:00
201 changed files with 13550 additions and 1501 deletions
+5
View File
@@ -9,3 +9,8 @@ bunfig.toml
xiaoju/
solve-issue-entry.ts
packages/workflow-template-develop/develop.esm.js
# Prevent wrong package manager lockfiles
pnpm-lock.yaml
package-lock.json
yarn.lock
-16
View File
@@ -1,16 +0,0 @@
import { createCursorAgent } from "./packages/workflow-agent-cursor/src/index.js";
import { createWorkflow } from "./packages/workflow-runtime/src/create-workflow.js";
import {
buildDevelopDescriptor,
developWorkflowDefinition,
} from "./packages/workflow-template-develop/src/index.js";
const agent = createCursorAgent({
command: "/home/azureuser/.local/bin/cursor-agent",
model: "auto",
timeout: 300_000,
workspace: null,
});
export const descriptor = buildDevelopDescriptor();
export const run = createWorkflow(developWorkflowDefinition, { adapter: agent, overrides: null });
+5 -2
View File
@@ -8,7 +8,7 @@
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32). No daemon — processes start on demand and exit when done.
The implementation lives in **15** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
The implementation lives in **21** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
## Package map
@@ -26,10 +26,13 @@ Grouped by responsibility (npm name → folder).
| CLI | `@uncaged/cli-workflow``cli-workflow` | `uncaged-workflow` binary (depends on engine, registry, CAS, protocol, util, runtime). |
| Agent adapters | `@uncaged/workflow-agent-cursor``workflow-agent-cursor` | `AgentFn` via `cursor-agent` CLI + workspace extraction. |
| | `@uncaged/workflow-agent-hermes``workflow-agent-hermes` | `AgentFn` via `hermes chat` CLI. |
| | `@uncaged/workflow-agent-office``workflow-agent-office` | `AdapterFn` via `office-agent` CLI; generates or edits Word documents, stores outputs per threadId. |
| | `@uncaged/workflow-agent-docx-diff``workflow-agent-docx-diff` | `AdapterFn` via `docx-diff` CLI; produces Word-format diff reports for document edit workflows. |
| | `@uncaged/workflow-agent-llm``workflow-agent-llm` | `AgentFn` via OpenAI-compatible HTTP (`LlmProvider` from runtime). |
| Agent shared | `@uncaged/workflow-util-agent``workflow-util-agent` | `buildAgentPrompt`, `spawnCli` for CLI-backed agents. |
| Templates | `@uncaged/workflow-template-develop``workflow-template-develop` | Develop workflow definition, roles, descriptor builder. |
| | `@uncaged/workflow-template-solve-issue``workflow-template-solve-issue` | Solve-issue workflow definition, roles, descriptor builder. |
| | `@uncaged/workflow-template-document``workflow-template-document` | Document generation/editing workflow definition (writer + differ roles, moderator table, descriptor). |
| Dashboard | `@uncaged/workflow-dashboard``workflow-dashboard` | Private Vite + React app (`src/main.tsx`); only `react` / `react-dom` dependencies — no workspace packages. |
## Dependency graph (workspace packages)
@@ -265,4 +268,4 @@ Tags are 8-char Crockford Base32 (40-bit random), one per call site. `grep "4KNM
| **Single-file ESM** | Hash = version, self-contained bundle |
| **No daemon** | OS handles process lifecycle |
| **Crockford Base32** | Filesystem-safe, readable, compact |
| **15-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
| **21-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
BIN
View File
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,387 @@
# 设计文档:office-agent 文档生成/编辑 Workflow 体系
**日期:** 2026-05-18
---
## 概述
在 monorepo 中新增三个包,实现通过 `office-agent` CLI 生成或编辑 Word 文档的完整 workflow 体系。
| 包 | npm name | 职责 |
|---|---|---|
| `workflow-template-document` | `@uncaged/workflow-template-document` | 纯结构:角色定义、meta schema、调度表、descriptor |
| `workflow-agent-office` | `@uncaged/workflow-agent-office` | writer 角色执行器:调用 `office-agent` CLI |
| `workflow-agent-docx-diff` | `@uncaged/workflow-agent-docx-diff` | differ 角色执行器:调用 `docx-diff` CLI |
Template 只定义结构,不含执行逻辑。执行器与 template 解耦。
---
## 一、`workflow-template-document`
### Thread 启动输入
```typescript
// src/types.ts
type DocumentStartInput = {
prompt: string; // 用户指令
inputDocx: string | null; // null = 生成模式;本机绝对路径 = 编辑模式
};
```
start.content 为 JSON `{ prompt, inputDocx }` 或纯文本(fallback:generate 模式,整段作为 prompt)。
### 角色与 Meta
`WriterMeta` 使用 discriminated union,在 schema 层区分两种模式:
```typescript
const writerMetaSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("generate"),
outputDocx: z.string(), // 生成产物绝对路径
sourceDocx: z.null(),
}),
z.object({
mode: z.literal("edit"),
outputDocx: z.string(), // 修改后产物:<outputDir>/modified.docx
sourceDocx: z.string(), // 原始副本:<outputDir>/original.docx
}),
]);
type WriterMeta = z.infer<typeof writerMetaSchema>;
// differ:仅编辑模式执行
const differMetaSchema = z.object({
sourceDocx: z.string(),
modifiedDocx: z.string(),
diffDocx: z.string(),
});
type DifferMeta = z.infer<typeof differMetaSchema>;
```
两个角色的 `systemPrompt` 均为 `""`
### 调度表
```
START → writer ──(mode = "edit")──→ differ → END
↘(mode = "generate")→ END
```
### 公开导出
template 导出两个对象供消费方使用:
- `documentWorkflowDefinition: WorkflowDefinition<DocumentMeta>` — 传入 `createWorkflow``def` 参数
- `buildDocumentDescriptor(): WorkflowDescriptor` — bundle 导出用
```typescript
// bundle 侧用法
export const descriptor = buildDocumentDescriptor();
export const run = createWorkflow(documentWorkflowDefinition, { adapter, overrides });
```
### 包文件结构
```
packages/workflow-template-document/
src/
types.ts # DocumentStartInput
roles/
writer.ts # writerMetaSchema, WriterMeta, writerRole
differ.ts # differMetaSchema, DifferMeta, differRole
index.ts
roles.ts # DocumentMeta, documentRoles
moderator.ts # writerIsEditMode condition + documentTable
definition.ts # documentWorkflowDefinition
descriptor.ts # buildDocumentDescriptor()
index.ts
__tests__/
moderator.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-register": "workspace:^",
"zod": "^4.0.0"
}
```
---
## 二、`workflow-agent-office`
### office-agent CLI 接口
```bash
# 生成模式:在 CWD 生成 output.docx
office-agent create "<prompt>" -o output.docx
# 编辑模式:在 CWD 对 modified.docx 进行修改(覆写)
office-agent edit modified.docx "<instruction>"
```
- 两个命令均为阻塞调用(CLI 内部消费 SSE,退出即完成)
- 输出文件落到调用方设定的 CWD
- 退出码 0 = 成功,非零 = 失败
### 文件命名约定
| 模式 | 文件 | 路径 |
|---|---|---|
| generate | 输出 | `<outputDir>/output.docx` |
| edit | 原始副本(workflow-owned 快照) | `<outputDir>/original.docx` |
| edit | 修改后产物 | `<outputDir>/modified.docx` |
edit 模式先将 `inputDocx` 复制为 `original.docx`(不可变快照),再复制为 `modified.docx`,对 `modified.docx` 调用 CLI。agent 覆写 `modified.docx``original.docx` 保持不变。differ 对比这两个 workflow-owned 文件,不依赖用户原始路径。
### 执行流程
**生成模式(`inputDocx = null`):**
1. `mkdir -p <outputDir>``<config.outputDir>/<ctx.threadId>`
2. `const command = config.command ?? "office-agent"`
3. `spawnCli(command, ["create", prompt, "-o", "output.docx"], { cwd: outputDir, timeoutMs })`
4. 验证 `outputDir/output.docx` 存在
5. 返回 `JSON.stringify({ mode: "generate", outputDocx, sourceDocx: null })`
**编辑模式(`inputDocx ≠ null`):**
1. `mkdir -p <outputDir>`
2. `copyFile(inputDocx, <outputDir>/original.docx)`
3. `copyFile(inputDocx, <outputDir>/modified.docx)`
4. `const command = config.command ?? "office-agent"`
5. `spawnCli(command, ["edit", "modified.docx", prompt], { cwd: outputDir, timeoutMs })`
6. 验证 `outputDir/modified.docx` 存在
7. 返回 `JSON.stringify({ mode: "edit", outputDocx: modifiedPath, sourceDocx: originalPath })`
### AdapterFn 实现(直接实现,不经过 runtime.extract)
CLI 产出确定性 JSON,直接 `schema.parse(JSON.parse(raw))` 跳过 LLM extraction:
```typescript
export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
return <T>(_systemPrompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const { prompt, inputDocx } = parseStartInput(ctx.start.content);
const raw = await runOfficeAgent(config, ctx.threadId, prompt, inputDocx);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
```
`_systemPrompt` 为 writer 角色的 systemPrompt(空字符串),实际指令从 `ctx.start.content` 解析。
### 配置
```typescript
type OfficeAgentConfig = {
outputDir: string; // 输出根目录,runner 在此下按 threadId 建子目录
command: string | null; // null → runner 内 resolve 为 "office-agent"
timeout: number | null; // null → 不设超时;单位 ms
};
```
### 错误处理
```typescript
if (!result.ok) {
const e = result.error;
if (e.kind === "non_zero_exit")
throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`);
if (e.kind === "timeout")
throw new Error("office-agent: timed out");
// "spawn_failed"
throw new Error(`office-agent: spawn failed: ${e.message}`);
}
if (!existsSync(expectedPath))
throw new Error(`office-agent: output file not found: ${expectedPath}`);
```
### packageDescriptor
```typescript
// src/package-descriptor.ts
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-office",
version: "0.1.0",
capabilities: ["office-agent-cli", "docx-generate", "docx-edit"],
configSchema: {
type: "object",
required: ["outputDir"],
properties: {
outputDir: { type: "string", description: "Root directory for workflow outputs." },
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to office-agent CLI; null uses PATH." },
timeout: { anyOf: [{ type: "number" }, { type: "null" }], description: "Timeout in ms; null means no limit." },
},
additionalProperties: false,
},
};
```
### 包文件结构
```
packages/workflow-agent-office/
src/
types.ts # OfficeAgentConfig, OfficeAgentOpt
runner.ts # runOfficeAgent()(spawnCli 封装 + 文件验证)
agent.ts # createOfficeAgent(): AdapterFn
package-descriptor.ts # packageDescriptor
index.ts
__tests__/
runner.test.ts
agent.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
}
```
---
## 三、`workflow-agent-docx-diff`
`differ` 角色专用执行器。从 `ctx.steps` 读取 `WriterMeta`,调用本地 `docx-diff` CLI。
### docx-diff 退出码约定
| 退出码 | 含义 | runner 处理 |
|---|---|---|
| 0 | 无差异 | 正常,验证 diffDocx 存在 |
| 1 | 有差异 | 正常(显式处理为成功),验证 diffDocx 存在 |
| 2+ | 错误 | throw |
runner 收到 `SpawnCliError { kind: "non_zero_exit", exitCode: 1 }` 时视为成功,验证文件后继续;`exitCode >= 2` 才 throw。
### 执行流程
```
1. 从 ctx.steps 找到 writer 步骤,读取 WriterMeta
2. 验证 mode === "edit"(否则 throw)
3. diffDocx = join(dirname(writer.outputDocx), "diff.docx")
4. const command = config.command ?? "docx-diff"
5. spawnCli(command,
[writer.sourceDocx, writer.outputDocx, "--output", "docx", "--out-file", diffDocx],
{ cwd: null, timeoutMs: null })
exit 0 或 1 → 验证 diffDocx 存在
exit 2+ → throw
6. 返回 JSON.stringify({ sourceDocx, modifiedDocx: writer.outputDocx, diffDocx })
```
### AdapterFn 实现(直接实现,不经过 runtime.extract)
```typescript
export function createDocxDiffAgent(config: DocxDiffAgentConfig = { command: null }): AdapterFn {
return <T>(_prompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const writerStep = ctx.steps.find(s => s.role === "writer");
if (!writerStep) throw new Error("differ: no writer step found");
const writerMeta = writerStep.meta as WriterMeta;
if (writerMeta.mode !== "edit")
throw new Error("differ: writer did not run in edit mode");
const raw = await runDocxDiff(config, writerMeta);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
```
### 配置
```typescript
type DocxDiffAgentConfig = {
command: string | null; // null → runner 内 resolve 为 "docx-diff"
};
```
### packageDescriptor
```typescript
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-docx-diff",
version: "0.1.0",
capabilities: ["docx-diff-cli", "docx-diff-report"],
configSchema: {
type: "object",
properties: {
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to docx-diff CLI; null uses PATH." },
},
additionalProperties: false,
},
};
```
### 包文件结构
```
packages/workflow-agent-docx-diff/
src/
types.ts # DocxDiffAgentConfig
runner.ts # runDocxDiff()(exit 1 处理 + 文件验证)
agent.ts # createDocxDiffAgent(): AdapterFn
package-descriptor.ts # packageDescriptor
index.ts
__tests__/
runner.test.ts
agent.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-template-document": "workspace:^"
}
```
---
## 四、外部 bundle(外部 workspace 消费)
```typescript
import { createOfficeAgent } from "@uncaged/workflow-agent-office";
import { createDocxDiffAgent } from "@uncaged/workflow-agent-docx-diff";
import {
buildDocumentDescriptor,
documentWorkflowDefinition,
} from "@uncaged/workflow-template-document";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
import { join } from "node:path";
const outputDir = join(getDefaultWorkflowStorageRoot(), "outputs");
export const descriptor = buildDocumentDescriptor();
export const run = createWorkflow(documentWorkflowDefinition, {
adapter: createOfficeAgent({ outputDir, command: null, timeout: null }),
overrides: { differ: createDocxDiffAgent() },
});
```
---
## 不在范围内
- 重试逻辑(失败直接 throw)
- office-agent server 的启停管理(假设 server 已在运行)
- docx-diff HTML/terminal 格式输出(仅 docx)
- 跨机器执行(`inputDocx` 须为本机有效绝对路径)
+527
View File
@@ -0,0 +1,527 @@
# `uwf` — Stateless Workflow CLI
> 将 workflow 引擎降维为无状态单步 CLI。Workflow 是纯数据(CAS 节点),执行是单步原子操作,agent 是可插拔外部命令。
---
## 1. CLI Design
### 1.1 命令总览
```
# thread 组
uwf thread start <workflow> -p <prompt> # 创建 thread,不执行
uwf thread step <thread-id> [--agent] # 单步执行
uwf thread show <thread-id> # thread-id → head 查询
uwf thread list [--all] # 列出活跃 threads(--all 含已归档)
uwf thread kill <thread-id> # 终结 thread,归档
# workflow 组
uwf workflow put <file.yaml> # 注册 workflow(YAML → CAS)
uwf workflow show <workflow-id> # 查看 workflow 定义
uwf workflow list # 列出已注册 workflows
```
两组对称,各 3-4 个子命令。CAS 操作交给 `json-cas` CLI,不在 `uwf` 中重复。
### 1.2 `uwf thread start`
```bash
uwf thread start <workflow> -p "Fix the login bug described in issue #42"
```
- `<workflow>` — workflow 名或 CAS hash
- `-p` — 用户 prompt(必填)
**输出(JSON to stdout):**
```jsonc
{
"workflow": "4KNM2PXR3B1QW", // workflow CAS hash (XXH64, 13-char Crockford Base32)
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T" // ULID
}
```
**做的事:**
1. 解析 workflow(名字查 registry → CAS hash)
2. 生成 thread ULID
3. 写 StartNode 到 CAS
4. 在 threads.yaml 中记录链头 → StartNode hash
5. 输出 JSON
### 1.3 `uwf thread step`
```bash
uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T
uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T --agent "bunx uwf-cursor"
```
**输出(JSON to stdout):**
```jsonc
{
"workflow": "4KNM2PXR3B1QW",
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T",
"head": "8FWKR3TN5V1QA", // 新链头 StepNode 的 CAS hash
"done": false // true = moderator 返回 END,thread 已归档
}
```
`done: true` 时 head 仍然有值(最后一个 StepNode),但 thread 已从 threads.yaml 移除。
对已结束或不存在的 thread 调用 step 会报错(非 active thread)。
详细信息通过 `uwf thread show <thread-id>``json-cas get <head>` 查看。
**做的事:**
1. 读链头 → 当前 StepNode(或 StartNode)
2. 收集 thread 历史(遍历链)
3. 调 moderator:评估 JSONata conditions → 得到下一个 role(或 END)
4. 若 END → 归档 thread,输出最后链头,退出
5. 确定 agent command(`--agent` override > config.yaml per-workflow/role > config.yaml defaultAgent)
6. 调用:`<agent-cmd> <thread-id> <role>`,捕获 stdout 得到新 StepNode hash
7. 更新链头指针
8. 再次调 moderator(基于新 StepNode)判断 done
9. 输出 JSON
### 1.4 `uwf thread show`
```bash
uwf thread show 01J7K9M2XNPQR5VWBCDF8G3H4T
```
**输出(JSON to stdout):**
```jsonc
{
"workflow": "4KNM2PXR3B1QW",
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T",
"head": "8FWKR3TN5V1QA",
"done": false
}
```
纯 thread-id → head 查询。详细内容用 `json-cas get <head>``json-cas walk <head>` 查看。
### 1.5 Agent CLI 协议
每个 agent 是一个命令,接受 thread-id 和 role 两个参数:
```bash
uwf-hermes <thread-id> <role>
```
**约定:**
- `uwf step` 负责 moderator 决策,将 role 传给 agent CLI
- agent-kit 根据 thread + role 从 CAS 读 systemPrompt / outputSchema
- agent-kit 组装完整 prompt(role systemPrompt + thread context + user prompt from StartNode)
- agent 执行实际逻辑,agent-kit 负责 extract
- agent 将 StepNode 写入 CAS(含 output、detail、agent、prev),但**不挪链头指针**
- stdout 输出新 StepNode 的 CAS hash(纯文本,一行)
- 所有配置从环境变量读(LLM model、API key、extractor config)
- exit 0 = 成功,非 0 = 失败
**stdout 输出:**
```
8FWKR3TN5V1QA
```
`uwf step` 拿到这个 hash 后更新链头指针、判断 done。
---
## 2. CAS 结构定义
### 2.1 类型层级
沿用 json-cas 的三层:bootstrap meta-schema → JSON Schema nodes → data nodes。
下面所有 CAS 节点都遵循 `{ type: cas_ref, payload: T, timestamp: number }` 的标准格式。
`cas_ref` 类型的字符串字段在 json-cas 中已内置支持,不需要额外的 `$ref` 包装。
### 2.2 数据节点
#### `Workflow`
Roles 和 moderator 内联在 Workflow 中,只有 outputSchema 独立为 CAS 节点(方便 json-cas 校验)。
```yaml
type: <workflow-schema-hash>
payload:
name: "solve-issue"
description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
systemPrompt: "You are a planning agent..."
outputSchema: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
developer:
description: "Implements code changes"
systemPrompt: "You are a developer agent..."
outputSchema: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
reviewer:
description: "Reviews code changes"
systemPrompt: "You are a code reviewer..."
outputSchema: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
conditions:
needsClarification:
description: "Planner requests clarification from user"
expression: "$exists(steps[-1].output.needsClarification)"
notApproved:
description: "Reviewer rejected the implementation"
expression: "steps[-1].output.approved = false"
graph:
$START:
- role: "planner"
condition: null # 无条件(fallback)
planner:
- role: "developer"
condition: "needsClarification"
- role: "$END"
condition: null
developer:
- role: "reviewer"
condition: null
reviewer:
- role: "developer"
condition: "notApproved"
- role: "$END"
condition: null
```
- `roles` — 内联定义,每个 role 的 `outputSchema` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
- `conditions``Record<Name, JSONata>`,命名条件,方便画图描述
- `graph``Record<Role | "$START", Transition[]>`,每个 Transition = `{ role, condition }`
- `condition` 引用 conditions 中的 key,`null` = fallback
- 按数组顺序求值,第一个匹配的 transition 胜出
- 不含 agent binding — agent 配置在 `~/.uncaged/workflow/config.yaml` 中管理
JSONata 表达式的求值上下文:
```jsonc
{
"start": { // StartNode 信息
"workflow": "4KNM2PXR3B1QW",
"prompt": "Fix the login bug..."
},
"steps": [ // 所有已完成 steps,从旧到新
{ "role": "planner", "output": { "phases": [...] }, "detail": "7BQST3VW9F2MA", "agent": "uwf-hermes" },
{ "role": "developer", "output": { "filesChanged": ["src/auth.ts"], "summary": "Fixed redirect" }, "detail": "9KRVW3TN5F1QA", "agent": "uwf-cursor" },
{ "role": "reviewer", "output": { "approved": false }, "detail": "2MXBG6PN4A8JR", "agent": "uwf-hermes" }
]
}
```
注:`output` 在上下文中会被自动展开为实际的 CAS 节点内容(而非 hash),方便 JSONata 表达式直接访问字段。
#### `StartNode`(Thread 起点)
```yaml
type: <start-node-schema-hash>
payload:
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
prompt: "Fix the login bug..."
```
- 没有 thread-id — thread-id 是索引层面的事,不进 CAS 内容
- 没有 agent binding — 运行时从 config.yaml 解析
#### `StepNode`(Thread 每一步)
```yaml
type: <step-node-schema-hash>
payload:
start: "4TNVW8KR2B3MA" # cas_ref → StartNode(每个 step 都引用)
prev: "2MXBG6PN4A8JR" # cas_ref → 前一个 StepNode,第一步为 null
role: "developer"
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 outputSchema)
detail: "7BQST3VW9F2MA" # cas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
agent: "uwf-cursor" # 实际使用的 agent 命令(纯字符串)
```
- `start` — 每个 StepNode 都直接引用 StartNode,方便随机访问
- `prev` — 前一个 StepNode 的 cas_ref,第一步为 `null`(不指向 StartNode)
- `output` — cas_ref,指向符合 role outputSchema 的 CAS 节点,可用 json-cas 校验
- `detail` — cas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
- `agent` — 纯字符串,不是 CAS 节点
### 2.3 链式结构
```
threads.yaml: { "01J7K9M2XNPQR5VWBCDF8G3H4T": "8FWKR3TN5V1QA" }
StepNode (step 3)
├── start ──→ StartNode
│ ├── workflow → CAS(Workflow)
│ └── prompt: "Fix..."
├── prev ──→ StepNode (step 2)
│ ├── start ──→ (same StartNode)
│ ├── prev ──→ StepNode (step 1)
│ │ ├── start ──→ (same StartNode)
│ │ ├── prev: null
│ │ ├── role: "planner"
│ │ └── ...
│ ├── role: "developer"
│ └── ...
├── role: "reviewer"
├── output → CAS({ approved: true })
├── detail → CAS(raw output | sub-workflow terminal node)
└── agent: "uwf-hermes"
```
### 2.4 可变状态
系统两个顶层 YAML 文件和一个 env 文件:
```yaml
# ~/.uncaged/workflow/config.yaml — 全局配置
providers:
openai:
baseUrl: "https://api.openai.com/v1"
apiKeyEnv: "OPENAI_API_KEY"
anthropic:
baseUrl: "https://api.anthropic.com/v1"
apiKeyEnv: "ANTHROPIC_API_KEY"
openrouter:
baseUrl: "https://openrouter.ai/api/v1"
apiKeyEnv: "OPENROUTER_API_KEY"
models:
sonnet:
provider: "openrouter"
name: "anthropic/claude-sonnet-4"
gpt4o-mini:
provider: "openai"
name: "gpt-4o-mini"
agents:
hermes:
command: "uwf-hermes"
args: []
cursor:
command: "uwf-cursor"
args: []
defaultAgent: "hermes"
agentOverrides:
solve-issue:
developer: "cursor"
defaultModel: "sonnet"
modelOverrides:
extract: "gpt4o-mini"
```
```yaml
# ~/.uncaged/workflow/threads.yaml — active thread 链头指针
01J7K9M2XNPQR5VWBCDF8G3H4T: "8FWKR3TN5V1QA"
01J8AB3QRMSTV6WKXZ2C4DF7GN: "3CNWT9KR6D2HV"
```
Thread 结束时从 threads.yaml 移除。可选:追加到 `history.jsonl` 做归档。
```bash
# ~/.uncaged/workflow/.env — 敏感信息(API keys)
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
OPENROUTER_API_KEY=sk-or-...
```
- `config.yaml` — 非敏感配置(agent 命令、model 名、provider 名)
- `.env` — 敏感信息(API keys),agent-kit 启动时自动加载
- `threads.yaml` — 运行时状态
---
## 3. 包结构
全新包,不复用现有 packages,避免命名冲突。CAS 直接依赖 `@uncaged/json-cas`
```
packages/
├── cli-uwf/ # @uncaged/cli-uwf — uwf CLI(thread/workflow 命令)
├── uwf-moderator/ # @uncaged/uwf-moderator — JSONata moderator 引擎
├── uwf-agent-kit/ # @uncaged/uwf-agent-kit — Agent CLI 框架(含 extractor)
├── uwf-agent-hermes/ # @uncaged/uwf-agent-hermes — uwf-hermes CLI
├── uwf-agent-cursor/ # @uncaged/uwf-agent-cursor — uwf-cursor CLI
└── uwf-protocol/ # @uncaged/uwf-protocol — 共享类型定义
```
**外部依赖:**
- `@uncaged/json-cas` — CAS 存储、hash、schema 校验
- `@uncaged/json-cas-fs` — 文件系统 CAS 后端
**现有包全部保留不动**,新旧并存,逐步迁移。
---
## 4. 关键数据类型
JSONata 求值上下文本质上是 thread 链表的线性化表达。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
### 4.1 公共类型
```typescript
/** CAS hash — XXH64, 13-char Crockford Base32 */
type CasRef = string;
/** Thread ID — ULID, 26-char Crockford Base32 */
type ThreadId = string;
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
type StepRecord = {
role: string;
output: CasRef; // cas_ref → 结构化输出节点(符合 role outputSchema)
detail: CasRef; // cas_ref → 执行详情(content node / 子 workflow terminal StepNode)
agent: string; // 实际使用的 agent 命令(纯字符串)
};
```
### 4.2 Workflow 定义
```typescript
type RoleDefinition = {
description: string;
systemPrompt: string;
outputSchema: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
};
type Transition = {
role: string; // 目标 role 名 或 "$END"
condition: string | null; // 引用 conditions 中的 key,null = fallback
};
type ConditionDefinition = {
description: string;
expression: string; // JSONata expression
};
type WorkflowPayload = {
name: string;
description: string;
roles: Record<string, RoleDefinition>;
conditions: Record<string, ConditionDefinition>;
graph: Record<string, Transition[]>; // Record<Role | "$START", Transition[]>
};
```
### 4.3 Thread 节点
```typescript
type StartNodePayload = {
workflow: CasRef; // cas_ref → Workflow
prompt: string;
};
type StepNodePayload = StepRecord & {
start: CasRef; // cas_ref → StartNode(每个 step 都引用)
prev: CasRef | null; // cas_ref → 前一个 StepNode,第一步为 null
};
```
### 4.4 JSONata 求值上下文
Thread 链表的线性化。`steps[n]` 的字段和 `StepRecord` 一致,但 `output` 被展开为实际内容。
```typescript
/** JSONata 上下文中的 step — output 被展开 */
type StepContext = Omit<StepRecord, "output"> & {
output: unknown; // 展开后的 CAS 节点内容,非 hash
};
type ModeratorContext = {
start: StartNodePayload;
steps: StepContext[]; // 从旧到新
};
```
### 4.5 CLI 输出
```typescript
/** uwf thread start */
type StartOutput = {
workflow: CasRef;
thread: ThreadId;
};
/** uwf thread step / uwf thread show */
type StepOutput = {
workflow: CasRef;
thread: ThreadId;
head: CasRef;
done: boolean;
};
/** uwf thread list */
type ThreadListItem = {
thread: ThreadId;
workflow: CasRef;
head: CasRef;
};
```
### 4.6 配置
```typescript
/** Alias types for config references */
type AgentAlias = string;
type ModelAlias = string;
type ProviderAlias = string;
type WorkflowName = string;
type RoleName = string;
type Scenario = string; // e.g. "extract"
type ProviderConfig = {
baseUrl: string;
apiKeyEnv: string; // env var name to read API key from
};
type ModelConfig = {
provider: ProviderAlias;
name: string; // e.g. "anthropic/claude-sonnet-4", "gpt-4o-mini"
};
type AgentConfig = {
command: string;
args: string[];
};
/** ~/.uncaged/workflow/config.yaml */
type WorkflowConfig = {
providers: Record<ProviderAlias, ProviderConfig>;
models: Record<ModelAlias, ModelConfig>;
agents: Record<AgentAlias, AgentConfig>;
defaultAgent: AgentAlias;
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
defaultModel: ModelAlias;
modelOverrides: Record<Scenario, ModelAlias> | null;
};
/** ~/.uncaged/workflow/threads.yaml */
type ThreadsIndex = Record<ThreadId, CasRef>;
// ^ thread-id ^ head StepNode/StartNode hash
```
### 4.7 类型关系图
```
WorkflowConfig (config.yaml)
ThreadsIndex (threads.yaml) ← 唯二可变状态
│ thread-id → head hash
StepNodePayload ──extends──→ StepRecord ←──maps to──→ StepContext
│ │ │
├── start → StartNodePayload│ │ (output 展开)
├── prev → StepNodePayload │ │
│ ├── role ├── role
│ ├── output (CasRef) ├── output (展开)
│ ├── detail (CasRef) ├── detail (CasRef)
│ └── agent (string) └── agent (string)
└── start.workflow → WorkflowPayload
├── roles: Record<name, RoleDefinition>
├── conditions: Record<name, JSONata>
└── graph: Record<role, Transition[]>
```
+6
View File
@@ -4,7 +4,13 @@
"workspaces": [
"packages/*"
],
"overrides": {
"@uncaged/json-cas": "^0.1.0",
"@uncaged/json-cas-workflow": "^0.1.0"
},
"packageManager": "bun@1.3.13",
"scripts": {
"preinstall": "node scripts/check-pkg-mgr.js",
"build": "bunx tsc --build",
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
"typecheck": "bunx tsc --build",
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@uncaged/cli-uwf",
"version": "0.1.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"bin": {
"uwf": "./src/cli.ts"
},
"dependencies": {
"@uncaged/json-cas": "^0.1.3",
"@uncaged/json-cas-fs": "^0.1.2",
"@uncaged/uwf-agent-kit": "workspace:^",
"@uncaged/uwf-moderator": "workspace:^",
"@uncaged/uwf-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"commander": "^14.0.3",
"dotenv": "^16.6.1",
"yaml": "^2.8.4"
},
"scripts": {
"test": "bun test"
},
"publishConfig": {
"access": "public"
}
}
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env bun
import { Command } from "commander";
import {
cmdThreadKill,
cmdThreadList,
cmdThreadShow,
cmdThreadStart,
cmdThreadStep,
} from "./commands/thread.js";
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
import { resolveStorageRoot } from "./store.js";
function writeJson(data: unknown): void {
process.stdout.write(`${JSON.stringify(data)}\n`);
}
function runAction(action: () => Promise<void>): void {
action().catch((e: unknown) => {
const message = e instanceof Error ? e.message : String(e);
process.stderr.write(`${message}\n`);
process.exit(1);
});
}
const program = new Command();
program.name("uwf").description("Stateless workflow CLI");
const workflow = program.command("workflow").description("Workflow registry and CAS");
workflow
.command("put")
.description("Register a workflow from YAML")
.argument("<file>", "Workflow YAML file")
.action((file: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdWorkflowPut(storageRoot, file);
writeJson(result);
});
});
workflow
.command("show")
.description("Show a workflow by name or CAS hash")
.argument("<id>", "Workflow name or hash")
.action((id: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdWorkflowShow(storageRoot, id);
writeJson(result);
});
});
workflow
.command("list")
.description("List registered workflows")
.action(() => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdWorkflowList(storageRoot);
writeJson(result);
});
});
const thread = program.command("thread").description("Thread lifecycle and execution");
thread
.command("start")
.description("Create a thread without executing")
.argument("<workflow>", "Workflow name or hash")
.requiredOption("-p, --prompt <text>", "User prompt")
.action((workflow: string, opts: { prompt: string }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt);
writeJson(result);
});
});
thread
.command("step")
.description("Execute one step")
.argument("<thread-id>", "Thread ULID")
.option("--agent <cmd>", "Override agent command")
.action((threadId: string, opts: { agent: string | undefined }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const agentOverride = opts.agent ?? null;
const result = await cmdThreadStep(storageRoot, threadId, agentOverride);
writeJson(result);
});
});
thread
.command("show")
.description("Show thread head pointer")
.argument("<thread-id>", "Thread ULID")
.action((threadId: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadShow(storageRoot, threadId);
writeJson(result);
});
});
thread
.command("list")
.description("List active threads")
.option("--all", "Include archived threads")
.action((opts: { all: boolean }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadList(storageRoot, opts.all);
writeJson(result);
});
});
thread
.command("kill")
.description("Terminate and archive a thread")
.argument("<thread-id>", "Thread ULID")
.action((threadId: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadKill(storageRoot, threadId);
writeJson(result);
});
});
program
.command("setup")
.description("Configure provider, model, and agent")
.option("--provider <name>", "Provider name")
.option("--base-url <url>", "OpenAI-compatible API base URL")
.option("--api-key <key>", "API key")
.option("--model <name>", "Default model name")
.option("--agent <name>", "Default agent alias")
.action((opts: {
provider?: string;
baseUrl?: string;
apiKey?: string;
model?: string;
agent?: string;
}) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
if (opts.provider && opts.baseUrl && opts.apiKey && opts.model) {
const result = await cmdSetup({
provider: opts.provider,
baseUrl: opts.baseUrl,
apiKey: opts.apiKey,
model: opts.model,
agent: opts.agent,
storageRoot,
});
writeJson(result);
} else if (!opts.provider && !opts.baseUrl && !opts.apiKey && !opts.model) {
await cmdSetupInteractive(storageRoot);
} else {
throw new Error(
"Non-interactive setup requires all of: --provider, --base-url, --api-key, --model",
);
}
});
});
program.parseAsync(process.argv).catch((e: unknown) => {
const message = e instanceof Error ? e.message : String(e);
process.stderr.write(`${message}\n`);
process.exit(1);
});
+332
View File
@@ -0,0 +1,332 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { stringify, parse } from "yaml";
/**
* Preset provider list — embedded to avoid runtime YAML loading dependency.
* Keep in sync with providers.yaml in cli-workflow.
*/
const PRESET_PROVIDERS = [
// International
{ name: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1" },
{ name: "xai", label: "xAI", baseUrl: "https://api.x.ai/v1" },
{ name: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1" },
{ name: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1" },
// China
{ name: "dashscope", label: "DashScope (Alibaba)", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" },
{ name: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" },
{ name: "siliconflow", label: "SiliconFlow", baseUrl: "https://api.siliconflow.cn/v1" },
{ name: "volcengine", label: "Volcengine (ByteDance)", baseUrl: "https://ark.cn-beijing.volces.com/api/v3" },
{ name: "kimi", label: "Kimi (Moonshot)", baseUrl: "https://api.moonshot.cn/v1" },
{ name: "glm", label: "GLM (Zhipu AI)", baseUrl: "https://open.bigmodel.cn/api/paas/v4" },
{ name: "stepfun", label: "StepFun", baseUrl: "https://api.stepfun.com/v1" },
{ name: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/v1" },
// Local
{ name: "ollama", label: "Ollama (local)", baseUrl: "http://localhost:11434/v1" },
] as const;
type SetupArgs = {
provider: string;
baseUrl: string;
apiKey: string;
model: string;
agent?: string;
storageRoot: string;
};
function getConfigPath(root: string): string {
return join(root, "config.yaml");
}
function getEnvPath(root: string): string {
return join(root, ".env");
}
/**
* Load existing config.yaml or return empty structure.
*/
function loadExistingConfig(configPath: string): Record<string, unknown> {
try {
if (existsSync(configPath)) {
const raw = parse(readFileSync(configPath, "utf8")) as unknown;
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
return raw as Record<string, unknown>;
}
}
} catch {
// ignore parse errors, start fresh
}
return {};
}
/**
* Load existing .env as key=value map.
*/
function loadEnvFile(envPath: string): Record<string, string> {
const env: Record<string, string> = {};
try {
if (existsSync(envPath)) {
for (const line of readFileSync(envPath, "utf8").split("\n")) {
const trimmed = line.trim();
if (trimmed === "" || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq > 0) {
env[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
}
}
}
} catch {
// ignore
}
return env;
}
function saveEnvFile(envPath: string, env: Record<string, string>): void {
const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`);
writeFileSync(envPath, `${lines.join("\n")}\n`, "utf8");
}
function apiKeyEnvName(providerName: string): string {
return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
}
/**
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
*/
function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record<string, unknown> {
const providers = (typeof existing.providers === "object" && existing.providers !== null
? { ...(existing.providers as Record<string, unknown>) }
: {}) as Record<string, unknown>;
const envName = apiKeyEnvName(args.provider);
providers[args.provider] = { baseUrl: args.baseUrl, apiKeyEnv: envName };
const models = (typeof existing.models === "object" && existing.models !== null
? { ...(existing.models as Record<string, unknown>) }
: {}) as Record<string, unknown>;
models.default = { provider: args.provider, name: args.model };
const agents = (typeof existing.agents === "object" && existing.agents !== null
? { ...(existing.agents as Record<string, unknown>) }
: {}) as Record<string, unknown>;
const agentName = args.agent ?? "hermes";
if (Object.keys(agents).length === 0) {
agents.hermes = { command: "uwf-hermes", args: [] };
}
return {
...existing,
providers,
models,
agents,
defaultAgent: existing.defaultAgent ?? agentName,
defaultModel: existing.defaultModel ?? "default",
};
}
/**
* Non-interactive setup. All required args provided via CLI flags.
*/
export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>> {
const { storageRoot } = args;
mkdirSync(storageRoot, { recursive: true });
const configPath = getConfigPath(storageRoot);
const envPath = getEnvPath(storageRoot);
const existing = loadExistingConfig(configPath);
const merged = mergeConfig(existing, args);
writeFileSync(configPath, stringify(merged, { indent: 2 }), "utf8");
// Write API key to .env
const envName = apiKeyEnvName(args.provider);
const envData = loadEnvFile(envPath);
envData[envName] = args.apiKey;
saveEnvFile(envPath, envData);
return {
configPath,
envPath,
provider: args.provider,
model: args.model,
defaultAgent: merged.defaultAgent,
};
}
/** Read a line with terminal echo disabled (for secrets). */
async function promptSecret(label: string): Promise<string> {
process.stdout.write(label);
return new Promise((resolve) => {
const rawWasSet = process.stdin.isRaw;
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.setEncoding("utf8");
let buf = "";
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");
resolve(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("*");
}
};
process.stdin.on("data", onData);
});
}
/** Fetch available models from an OpenAI-compatible /models endpoint. */
async function fetchModels(baseUrl: string, apiKey: string): Promise<string[]> {
try {
const url = `${baseUrl.replace(/\/+$/, "")}/models`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return [];
const body = (await res.json()) as { data?: { id: string }[] };
if (!Array.isArray(body.data)) return [];
const NON_CHAT = /speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|gui-/i;
return body.data.map((m) => m.id).filter((id) => !NON_CHAT.test(id)).sort();
} catch {
return [];
}
}
/**
* Interactive setup — prompts user for provider, API key, model.
*/
export async function cmdSetupInteractive(storageRoot: string): Promise<Record<string, unknown>> {
const rl = createInterface({ input, output });
try {
console.log("Configure LLM provider for uwf workflow agents.\n");
// 1. Provider selection
const numWidth = String(PRESET_PROVIDERS.length + 1).length;
console.log("Select a provider:\n");
for (let i = 0; i < PRESET_PROVIDERS.length; i++) {
const p = PRESET_PROVIDERS[i];
if (!p) continue;
const num = String(i + 1).padStart(numWidth);
console.log(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
}
const customNum = String(PRESET_PROVIDERS.length + 1).padStart(numWidth);
console.log(` ${customNum}) Custom (enter name and URL manually)\n`);
const choice = (await rl.question(`Choose [1-${PRESET_PROVIDERS.length + 1}]: `)).trim();
const choiceNum = Number.parseInt(choice, 10);
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > PRESET_PROVIDERS.length + 1) {
throw new Error(`Invalid choice: ${choice}`);
}
let providerName: string;
let baseUrl: string;
if (choiceNum <= PRESET_PROVIDERS.length) {
const selected = PRESET_PROVIDERS[choiceNum - 1];
if (!selected) throw new Error("Invalid selection");
providerName = selected.name;
baseUrl = selected.baseUrl;
console.log(`\n → ${selected.label} (${selected.baseUrl})\n`);
} else {
providerName = (await rl.question("Provider name (e.g. my-proxy): ")).trim();
if (!providerName) throw new Error("Provider name required");
baseUrl = (await rl.question("OpenAI-compatible API base URL: ")).trim();
if (!baseUrl) throw new Error("Base URL required");
}
// 2. API key
rl.close();
const apiKey = await promptSecret("API key: ");
if (!apiKey) throw new Error("API key required");
// 3. Model selection
const rl2 = createInterface({ input, output });
console.log("\nFetching available models...");
const models = await fetchModels(baseUrl, apiKey);
let model: string;
if (models.length > 0) {
console.log(`\nAvailable models (${models.length}):\n`);
const nw = String(models.length).length;
// Multi-column layout
const maxLen = models.reduce((m, s) => Math.max(m, s.length), 0);
const colWidth = nw + 2 + maxLen + 4; // " N) name "
const termCols = process.stdout.columns || 100;
const cols = Math.max(1, Math.floor(termCols / colWidth));
const rows = Math.ceil(models.length / cols);
for (let r = 0; r < rows; r++) {
let line = "";
for (let c = 0; c < cols; c++) {
const idx = c * rows + r;
if (idx >= models.length) break;
const num = String(idx + 1).padStart(nw);
const name = (models[idx] ?? "").padEnd(maxLen);
line += ` ${num}) ${name} `;
}
console.log(line.trimEnd());
}
console.log(`\nChoose a number, or type a model name directly.`);
const modelInput = (await rl2.question(`Default model [1-${models.length}]: `)).trim();
if (!modelInput) throw new Error("Model required");
const modelNum = Number.parseInt(modelInput, 10);
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
model = models[modelNum - 1] ?? modelInput;
} else {
model = modelInput;
}
} else {
console.log("Could not fetch models. Enter model name manually.");
model = (await rl2.question("Default model (e.g. qwen-plus, gpt-4o): ")).trim();
if (!model) throw new Error("Model required");
}
rl2.close();
console.log(`${providerName}/${model}\n`);
await cmdSetup({
provider: providerName,
baseUrl,
apiKey,
model,
storageRoot,
});
console.log("Setup complete! Get started:\n");
console.log(" uwf workflow put <workflow.yaml> Register a workflow");
console.log(' uwf thread start <name> -p "..." Start a thread');
console.log(" uwf thread step <thread-id> Execute next step");
console.log("");
return null;
} finally {
rl.close();
}
}
+465
View File
@@ -0,0 +1,465 @@
import { execFileSync } from "node:child_process";
import { validate } from "@uncaged/json-cas";
import { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit";
import { evaluate } from "@uncaged/uwf-moderator";
import type {
AgentAlias,
AgentConfig,
CasRef,
ModeratorContext,
StartNodePayload,
StartOutput,
StepContext,
StepNodePayload,
StepOutput,
ThreadId,
ThreadListItem,
WorkflowConfig,
WorkflowPayload,
} from "@uncaged/uwf-protocol";
import { generateUlid } from "@uncaged/workflow-util";
import { config as loadDotenv } from "dotenv";
import {
appendThreadHistory,
createUwfStore,
findThreadInHistory,
loadThreadHistory,
loadThreadsIndex,
loadWorkflowRegistry,
resolveWorkflowHash,
saveThreadsIndex,
type ThreadHistoryLine,
type UwfStore,
} from "../store.js";
import { isCasRef } from "../validate.js";
const END_ROLE = "$END";
type ChainState = {
startHash: CasRef;
start: StartNodePayload;
stepsNewestFirst: StepNodePayload[];
headIsStart: boolean;
};
export type KillOutput = {
thread: ThreadId;
archived: boolean;
};
function fail(message: string): never {
process.stderr.write(`${message}\n`);
process.exit(1);
}
async function resolveWorkflowCasRef(
uwf: UwfStore,
storageRoot: string,
workflowId: string,
): Promise<CasRef> {
const registry = await loadWorkflowRegistry(storageRoot);
const hash = resolveWorkflowHash(registry, workflowId);
if (!isCasRef(hash)) {
fail(`workflow not found: ${workflowId}`);
}
const node = uwf.store.get(hash);
if (node === null) {
fail(`CAS node not found: ${hash}`);
}
if (node.type !== uwf.schemas.workflow) {
fail(`node ${hash} is not a Workflow (type ${node.type})`);
}
return hash;
}
function resolveWorkflowFromHead(uwf: UwfStore, head: CasRef): CasRef | null {
const node = uwf.store.get(head);
if (node === null) {
return null;
}
if (node.type === uwf.schemas.startNode) {
const payload = node.payload as StartNodePayload;
return payload.workflow;
}
const payload = node.payload as StepNodePayload;
if (typeof payload.start !== "string") {
return null;
}
const startNode = uwf.store.get(payload.start);
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
return null;
}
return (startNode.payload as StartNodePayload).workflow;
}
export async function cmdThreadStart(
storageRoot: string,
workflowId: string,
prompt: string,
): Promise<StartOutput> {
const uwf = await createUwfStore(storageRoot);
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId);
const threadId = generateUlid(Date.now()) as ThreadId;
const startPayload: StartNodePayload = {
workflow: workflowHash,
prompt,
};
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
const node = uwf.store.get(headHash);
if (node === null || !validate(uwf.store, node)) {
fail("stored StartNode failed schema validation");
}
const index = await loadThreadsIndex(storageRoot);
index[threadId] = headHash;
await saveThreadsIndex(storageRoot, index);
return { workflow: workflowHash, thread: threadId };
}
export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Promise<StepOutput> {
const index = await loadThreadsIndex(storageRoot);
const activeHead = index[threadId];
if (activeHead !== undefined) {
const uwf = await createUwfStore(storageRoot);
const workflow = resolveWorkflowFromHead(uwf, activeHead);
if (workflow === null) {
fail(`failed to resolve workflow from head: ${activeHead}`);
}
return {
workflow,
thread: threadId,
head: activeHead,
done: false,
};
}
const hist = await findThreadInHistory(storageRoot, threadId);
if (hist !== null) {
return {
workflow: hist.workflow,
thread: threadId,
head: hist.head,
done: true,
};
}
fail(`thread not found: ${threadId}`);
}
async function threadListItemFromActive(
uwf: UwfStore,
threadId: ThreadId,
head: CasRef,
): Promise<ThreadListItem | null> {
const workflow = resolveWorkflowFromHead(uwf, head);
if (workflow === null) {
return null;
}
return { thread: threadId, workflow, head };
}
export async function cmdThreadList(
storageRoot: string,
includeAll: boolean,
): Promise<ThreadListItem[]> {
const uwf = await createUwfStore(storageRoot);
const index = await loadThreadsIndex(storageRoot);
const items: ThreadListItem[] = [];
for (const [threadId, head] of Object.entries(index)) {
const item = await threadListItemFromActive(uwf, threadId as ThreadId, head);
if (item !== null) {
items.push(item);
}
}
if (!includeAll) {
return items;
}
const activeIds = new Set(items.map((i) => i.thread));
const history = await loadThreadHistory(storageRoot);
for (const entry of history) {
if (!activeIds.has(entry.thread)) {
items.push({
thread: entry.thread,
workflow: entry.workflow,
head: entry.head,
});
}
}
return items;
}
function walkChain(uwf: UwfStore, headHash: CasRef): ChainState {
const headNode = uwf.store.get(headHash);
if (headNode === null) {
fail(`CAS node not found: ${headHash}`);
}
if (headNode.type === uwf.schemas.startNode) {
return {
startHash: headHash,
start: headNode.payload as StartNodePayload,
stepsNewestFirst: [],
headIsStart: true,
};
}
if (headNode.type !== uwf.schemas.stepNode) {
fail(`head ${headHash} is not a StartNode or StepNode`);
}
const stepsNewestFirst: StepNodePayload[] = [];
let hash: CasRef | null = headHash;
while (hash !== null) {
const node = uwf.store.get(hash);
if (node === null) {
fail(`CAS node not found while walking chain: ${hash}`);
}
if (node.type !== uwf.schemas.stepNode) {
break;
}
const payload = node.payload as StepNodePayload;
stepsNewestFirst.push(payload);
hash = payload.prev;
}
const newest = stepsNewestFirst[0];
if (newest === undefined) {
fail(`empty step chain at head ${headHash}`);
}
const startNode = uwf.store.get(newest.start);
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
fail(`StartNode not found: ${newest.start}`);
}
return {
startHash: newest.start,
start: startNode.payload as StartNodePayload,
stepsNewestFirst,
headIsStart: false,
};
}
function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
const node = uwf.store.get(outputRef);
if (node === null) {
return {};
}
return node.payload;
}
function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
const chronological = [...chain.stepsNewestFirst].reverse();
const steps: StepContext[] = chronological.map((step) => ({
role: step.role,
output: expandOutput(uwf, step.output),
detail: step.detail,
agent: step.agent,
}));
return { start: chain.start, steps };
}
function loadWorkflowPayload(uwf: UwfStore, workflowRef: CasRef): WorkflowPayload {
const node = uwf.store.get(workflowRef);
if (node === null) {
fail(`workflow CAS node not found: ${workflowRef}`);
}
if (node.type !== uwf.schemas.workflow) {
fail(`node ${workflowRef} is not a Workflow`);
}
return node.payload as WorkflowPayload;
}
function parseAgentOverride(override: string): AgentConfig {
const parts = override
.trim()
.split(/\s+/)
.filter((p) => p.length > 0);
const command = parts[0];
if (command === undefined) {
fail("agent override must not be empty");
}
return { command, args: parts.slice(1) };
}
function resolveAgentConfig(
config: WorkflowConfig,
workflow: WorkflowPayload,
role: string,
agentOverride: string | null,
): AgentConfig {
if (agentOverride !== null) {
return parseAgentOverride(agentOverride);
}
let alias: AgentAlias = config.defaultAgent;
if (config.agentOverrides !== null) {
const roleOverrides = config.agentOverrides[workflow.name];
if (roleOverrides !== undefined && roleOverrides[role] !== undefined) {
alias = roleOverrides[role];
}
}
const agentConfig = config.agents[alias];
if (agentConfig === undefined) {
fail(`unknown agent alias in config: ${alias}`);
}
return agentConfig;
}
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): CasRef {
const argv = [...agent.args, threadId, role];
let stdout: string;
try {
stdout = execFileSync(agent.command, argv, {
encoding: "utf8",
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
} catch (e) {
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string };
const stderr =
err.stderr === undefined
? ""
: typeof err.stderr === "string"
? err.stderr
: err.stderr.toString("utf8");
const detail = stderr.trim() !== "" ? `: ${stderr.trim()}` : "";
fail(`agent command failed (${agent.command})${detail}`);
}
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
if (!isCasRef(line)) {
fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
}
return line;
}
async function archiveThread(
storageRoot: string,
threadId: ThreadId,
workflow: CasRef,
head: CasRef,
): Promise<void> {
const index = await loadThreadsIndex(storageRoot);
delete index[threadId];
await saveThreadsIndex(storageRoot, index);
await appendThreadHistory(storageRoot, {
thread: threadId,
workflow,
head,
completedAt: Date.now(),
});
}
export async function cmdThreadStep(
storageRoot: string,
threadId: ThreadId,
agentOverride: string | null,
): Promise<StepOutput> {
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId];
if (headHash === undefined) {
fail(`thread not active: ${threadId}`);
}
const uwf = await createUwfStore(storageRoot);
const chain = walkChain(uwf, headHash);
const workflowHash = chain.start.workflow;
const workflow = loadWorkflowPayload(uwf, workflowHash);
const context = buildModeratorContext(uwf, chain);
const nextResult = await evaluate(workflow, context);
if (!nextResult.ok) {
fail(nextResult.error.message);
}
if (nextResult.value === END_ROLE) {
await archiveThread(storageRoot, threadId, workflowHash, headHash);
return {
workflow: workflowHash,
thread: threadId,
head: headHash,
done: true,
};
}
const role = nextResult.value;
const config = await loadWorkflowConfig(storageRoot);
const agent = resolveAgentConfig(config, workflow, role, agentOverride);
loadDotenv({ path: getEnvPath(storageRoot) });
const newHead = spawnAgent(agent, threadId, role);
// Re-create store to pick up nodes written by the agent subprocess
const uwfAfter = await createUwfStore(storageRoot);
const newNode = uwfAfter.store.get(newHead);
if (newNode === null || newNode.type !== uwfAfter.schemas.stepNode) {
fail(`agent returned hash that is not a StepNode: ${newHead}`);
}
// Reload threads index to avoid overwriting changes made by the agent subprocess
const freshIndex = await loadThreadsIndex(storageRoot);
freshIndex[threadId] = newHead;
await saveThreadsIndex(storageRoot, freshIndex);
const chainAfter = walkChain(uwfAfter, newHead);
const contextAfter = buildModeratorContext(uwfAfter, chainAfter);
const afterResult = await evaluate(workflow, contextAfter);
if (!afterResult.ok) {
fail(afterResult.error.message);
}
const done = afterResult.value === END_ROLE;
if (done) {
await archiveThread(storageRoot, threadId, workflowHash, newHead);
}
return {
workflow: workflowHash,
thread: threadId,
head: newHead,
done,
};
}
export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Promise<KillOutput> {
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
if (head === undefined) {
fail(`thread not active: ${threadId}`);
}
const uwf = await createUwfStore(storageRoot);
const workflow = resolveWorkflowFromHead(uwf, head);
if (workflow === null) {
fail(`failed to resolve workflow from head: ${head}`);
}
delete index[threadId];
await saveThreadsIndex(storageRoot, index);
const historyEntry: ThreadHistoryLine = {
thread: threadId,
workflow,
head,
completedAt: Date.now(),
};
await appendThreadHistory(storageRoot, historyEntry);
return { thread: threadId, archived: true };
}
+157
View File
@@ -0,0 +1,157 @@
import { readFile } from "node:fs/promises";
import type { JSONSchema } from "@uncaged/json-cas";
import { putSchema, validate } from "@uncaged/json-cas";
import type { CasRef, RoleDefinition, WorkflowPayload } from "@uncaged/uwf-protocol";
import { parse } from "yaml";
import {
createUwfStore,
findRegistryName,
loadWorkflowRegistry,
resolveWorkflowHash,
saveWorkflowRegistry,
type UwfStore,
} from "../store.js";
import { isCasRef, parseWorkflowPayload } from "../validate.js";
export type WorkflowListEntry = {
name: string;
hash: CasRef;
};
export type WorkflowPutOutput = {
name: string;
hash: CasRef;
};
export type WorkflowShowOutput = {
hash: CasRef;
name: string | null;
type: CasRef;
payload: WorkflowPayload;
timestamp: number;
};
function fail(message: string): never {
process.stderr.write(`${message}\n`);
process.exit(1);
}
function isJsonSchema(value: unknown): value is JSONSchema {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function resolveOutputSchemaRef(
uwf: UwfStore,
outputSchema: string | JSONSchema,
): Promise<CasRef> {
if (typeof outputSchema === "string") {
if (!isCasRef(outputSchema)) {
fail(`invalid outputSchema cas_ref: ${outputSchema}`);
}
if (!uwf.store.has(outputSchema)) {
fail(`outputSchema not found in CAS: ${outputSchema}`);
}
return outputSchema;
}
if (!isJsonSchema(outputSchema)) {
fail("outputSchema must be a cas_ref string or JSON Schema object");
}
return putSchema(uwf.store, outputSchema);
}
async function materializeWorkflowPayload(
uwf: UwfStore,
raw: WorkflowPayload,
): Promise<WorkflowPayload> {
const roles: Record<string, RoleDefinition> = {};
for (const [roleName, role] of Object.entries(raw.roles)) {
const outputSchema = await resolveOutputSchemaRef(
uwf,
role.outputSchema as string | JSONSchema,
);
roles[roleName] = {
description: role.description,
systemPrompt: role.systemPrompt,
outputSchema,
};
}
return {
name: raw.name,
description: raw.description,
roles,
conditions: raw.conditions,
graph: raw.graph,
};
}
export async function cmdWorkflowPut(
storageRoot: string,
filePath: string,
): Promise<WorkflowPutOutput> {
let text: string;
try {
text = await readFile(filePath, "utf8");
} catch {
fail(`file not found: ${filePath}`);
}
let raw: unknown;
try {
raw = parse(text) as unknown;
} catch (e) {
fail(`invalid YAML: ${e instanceof Error ? e.message : String(e)}`);
}
const payload = parseWorkflowPayload(raw);
if (payload === null) {
fail("invalid workflow YAML: expected WorkflowPayload shape");
}
const uwf = await createUwfStore(storageRoot);
const materialized = await materializeWorkflowPayload(uwf, payload);
const hash = await uwf.store.put(uwf.schemas.workflow, materialized);
const node = uwf.store.get(hash);
if (node === null || !validate(uwf.store, node)) {
fail("stored workflow failed schema validation");
}
const registry = await loadWorkflowRegistry(storageRoot);
registry[materialized.name] = hash;
await saveWorkflowRegistry(storageRoot, registry);
return { name: materialized.name, hash };
}
export async function cmdWorkflowShow(
storageRoot: string,
id: string,
): Promise<WorkflowShowOutput> {
const uwf = await createUwfStore(storageRoot);
const registry = await loadWorkflowRegistry(storageRoot);
const hash = resolveWorkflowHash(registry, id);
const node = uwf.store.get(hash);
if (node === null) {
fail(`CAS node not found: ${hash}`);
}
if (node.type !== uwf.schemas.workflow) {
fail(`node ${hash} is not a Workflow (type ${node.type})`);
}
const payload = node.payload as WorkflowPayload;
return {
hash,
name: findRegistryName(registry, hash),
type: node.type,
payload,
timestamp: node.timestamp,
};
}
export async function cmdWorkflowList(storageRoot: string): Promise<WorkflowListEntry[]> {
const registry = await loadWorkflowRegistry(storageRoot);
return Object.entries(registry).map(([name, hash]) => ({ name, hash }));
}
+26
View File
@@ -0,0 +1,26 @@
import type { Hash, Store } from "@uncaged/json-cas";
import { putSchema } from "@uncaged/json-cas";
import {
START_NODE_SCHEMA,
STEP_NODE_SCHEMA,
WORKFLOW_SCHEMA,
} from "@uncaged/uwf-protocol";
export type UwfSchemaHashes = {
workflow: Hash;
startNode: Hash;
stepNode: Hash;
};
/**
* Register Workflow, StartNode, and StepNode JSON Schemas in the CAS store.
* Idempotent: safe to call on every CLI invocation.
*/
export async function registerUwfSchemas(store: Store): Promise<UwfSchemaHashes> {
const [workflow, startNode, stepNode] = await Promise.all([
putSchema(store, WORKFLOW_SCHEMA),
putSchema(store, START_NODE_SCHEMA),
putSchema(store, STEP_NODE_SCHEMA),
]);
return { workflow, startNode, stepNode };
}
+212
View File
@@ -0,0 +1,212 @@
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Hash, Store } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, ThreadId, ThreadListItem, ThreadsIndex } from "@uncaged/uwf-protocol";
import { parse, stringify } from "yaml";
import { registerUwfSchemas, type UwfSchemaHashes } from "./schemas.js";
export type WorkflowRegistry = Record<string, CasRef>;
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
export function getDefaultStorageRoot(): string {
return join(homedir(), ".uncaged", "workflow");
}
/**
* Resolve storage root.
* Priority: `UNCAGED_WORKFLOW_STORAGE_ROOT` → `WORKFLOW_STORAGE_ROOT` → default.
*/
export function resolveStorageRoot(): string {
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
if (internal !== undefined && internal !== "") {
return internal;
}
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
if (userOverride !== undefined && userOverride !== "") {
return userOverride;
}
return getDefaultStorageRoot();
}
export function getCasDir(storageRoot: string): string {
return join(storageRoot, "cas");
}
export function getRegistryPath(storageRoot: string): string {
return join(storageRoot, "workflows.yaml");
}
export function getThreadsPath(storageRoot: string): string {
return join(storageRoot, "threads.yaml");
}
export function getHistoryPath(storageRoot: string): string {
return join(storageRoot, "history.jsonl");
}
export type ThreadHistoryLine = ThreadListItem & {
completedAt: number;
};
export type UwfStore = {
storageRoot: string;
store: Store;
schemas: UwfSchemaHashes;
};
export async function createUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = getCasDir(storageRoot);
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
return { storageRoot, store, schemas };
}
export async function loadWorkflowRegistry(storageRoot: string): Promise<WorkflowRegistry> {
const path = getRegistryPath(storageRoot);
try {
const text = await readFile(path, "utf8");
const raw = parse(text) as unknown;
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
const registry: WorkflowRegistry = {};
for (const [name, hash] of Object.entries(raw as Record<string, unknown>)) {
if (typeof hash === "string") {
registry[name] = hash;
}
}
return registry;
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
return {};
}
throw e;
}
}
export async function saveWorkflowRegistry(
storageRoot: string,
registry: WorkflowRegistry,
): Promise<void> {
const path = getRegistryPath(storageRoot);
await mkdir(storageRoot, { recursive: true });
const text = stringify(registry, { indent: 2 });
await writeFile(path, text, "utf8");
}
export function resolveWorkflowHash(registry: WorkflowRegistry, id: string): CasRef {
return registry[id] !== undefined ? registry[id] : id;
}
export function findRegistryName(registry: WorkflowRegistry, hash: Hash): string | null {
for (const [name, h] of Object.entries(registry)) {
if (h === hash) {
return name;
}
}
return null;
}
export async function loadThreadsIndex(storageRoot: string): Promise<ThreadsIndex> {
const path = getThreadsPath(storageRoot);
try {
const text = await readFile(path, "utf8");
const raw = parse(text) as unknown;
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
const index: ThreadsIndex = {};
for (const [threadId, head] of Object.entries(raw as Record<string, unknown>)) {
if (typeof head === "string") {
index[threadId as ThreadId] = head;
}
}
return index;
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
return {};
}
throw e;
}
}
export async function saveThreadsIndex(storageRoot: string, index: ThreadsIndex): Promise<void> {
const path = getThreadsPath(storageRoot);
await mkdir(storageRoot, { recursive: true });
const text = stringify(index, { indent: 2 });
await writeFile(path, text, "utf8");
}
export async function loadThreadHistory(storageRoot: string): Promise<ThreadHistoryLine[]> {
const path = getHistoryPath(storageRoot);
try {
const text = await readFile(path, "utf8");
const lines: ThreadHistoryLine[] = [];
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (trimmed === "") {
continue;
}
let raw: unknown;
try {
raw = JSON.parse(trimmed) as unknown;
} catch {
continue;
}
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
continue;
}
const rec = raw as Record<string, unknown>;
const thread = rec.thread;
const workflow = rec.workflow;
const head = rec.head;
const completedAt = rec.completedAt;
if (
typeof thread === "string" &&
typeof workflow === "string" &&
typeof head === "string" &&
typeof completedAt === "number"
) {
lines.push({ thread: thread as ThreadId, workflow, head, completedAt });
}
}
return lines;
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
return [];
}
throw e;
}
}
export async function findThreadInHistory(
storageRoot: string,
threadId: ThreadId,
): Promise<ThreadHistoryLine | null> {
const history = await loadThreadHistory(storageRoot);
for (let i = history.length - 1; i >= 0; i--) {
const entry = history[i];
if (entry !== undefined && entry.thread === threadId) {
return entry;
}
}
return null;
}
export async function appendThreadHistory(
storageRoot: string,
entry: ThreadHistoryLine,
): Promise<void> {
const path = getHistoryPath(storageRoot);
await mkdir(storageRoot, { recursive: true });
const line = `${JSON.stringify(entry)}\n`;
await appendFile(path, line, "utf8");
}
+73
View File
@@ -0,0 +1,73 @@
import type { CasRef, WorkflowPayload } from "@uncaged/uwf-protocol";
const CAS_REF_PATTERN = /^[0-9A-HJKMNP-TV-Z]{13}$/;
export function isCasRef(value: string): value is CasRef {
return CAS_REF_PATTERN.test(value);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isRoleDefinition(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
const outputSchema = value.outputSchema;
const schemaOk =
typeof outputSchema === "string" ||
(isRecord(outputSchema) && typeof outputSchema.type === "string");
return (
typeof value.description === "string" && typeof value.systemPrompt === "string" && schemaOk
);
}
function isConditionDefinition(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
return typeof value.description === "string" && typeof value.expression === "string";
}
function isTransition(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
const condition = value.condition;
return typeof value.role === "string" && (condition === null || typeof condition === "string");
}
function isStringRecord(value: unknown, itemCheck: (item: unknown) => boolean): boolean {
if (!isRecord(value)) {
return false;
}
return Object.values(value).every(itemCheck);
}
function isGraph(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
return Object.values(value).every(
(transitions) => Array.isArray(transitions) && transitions.every((t) => isTransition(t)),
);
}
/** Validate YAML-parsed workflow document shape (outputSchema may be inline JSON Schema). */
export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
if (!isRecord(raw)) {
return null;
}
if (typeof raw.name !== "string" || typeof raw.description !== "string") {
return null;
}
if (
!isStringRecord(raw.roles, isRoleDefinition) ||
!isStringRecord(raw.conditions, isConditionDefinition) ||
!isGraph(raw.graph)
) {
return null;
}
return raw as WorkflowPayload;
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [
{ "path": "../uwf-protocol" },
{ "path": "../uwf-moderator" },
{ "path": "../uwf-agent-kit" }
]
}
@@ -20,9 +20,6 @@ import { addCliArgs } from "./bundle-fixture.js";
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {}, graph: { edges: [] } };
`;
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
`;
function casStoredForm(raw: string): string {
return serializeMerkleNode(createContentMerkleNode(raw));
}
@@ -52,12 +49,12 @@ describe("cli workflow commands", () => {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}import fs from "node:fs";
`${fixtureDescriptor}import fs from "node:fs";
export const run = async function* (input, options) {
fs.existsSync(".");
const cas = options.cas;
const h = await putContentMerkleNode(cas, input.prompt);
const h = await cas.put(input.prompt);
yield { role: "noop", contentHash: h, meta: { done: true }, refs: [h] };
return { returnCode: 0, summary: "done" };
}
@@ -155,10 +152,9 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
},
graph: { edges: [] },
};
${wfPutImport}
export const run = async function* (input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, input.prompt);
const h = await cas.put( input.prompt);
yield { role: "greeter", contentHash: h, meta: { greeting: "hi" }, refs: [h] };
return { returnCode: 0, summary: "ok" };
};
@@ -197,9 +193,9 @@ export const run = async function* (input, options) {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
const h = await cas.put( "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
@@ -228,9 +224,9 @@ export const run = async function* (input, options) {
const dtsPath = join(bundleDir, "types.d.ts");
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
const h = await cas.put( "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
@@ -261,9 +257,9 @@ export const run = async function* (input, options) {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
const h = await cas.put( "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
@@ -284,16 +280,16 @@ export const run = async function* (input, options) {
const bundleDir = join(storageRoot, "src");
await mkdir(bundleDir, { recursive: true });
const bundlePath = join(bundleDir, "demo.esm.js");
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "v1");
const h = await cas.put( "v1");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "v1" };
}
`;
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "v2");
const h = await cas.put( "v2");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "v2" };
}
@@ -326,16 +322,16 @@ export const run = async function* (input, options) {
const bundleDir = join(storageRoot, "src");
await mkdir(bundleDir, { recursive: true });
const bundlePath = join(bundleDir, "demo.esm.js");
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "v1");
const h = await cas.put( "v1");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "v1" };
}
`;
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "v2");
const h = await cas.put( "v2");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "v2" };
}
@@ -378,9 +374,9 @@ export const run = async function* (input, options) {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
const h = await cas.put( "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
@@ -391,9 +387,9 @@ export const run = async function* (input, options) {
expect(add1.ok).toBe(true);
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "y");
const h = await cas.put( "y");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "y" };
}
@@ -446,9 +442,9 @@ export const run = async function* (input, options) {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
const h = await cas.put( "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
@@ -463,9 +459,9 @@ export const run = async function* (input, options) {
const hash1 = add1.value.hash;
await writeFile(
bundlePath,
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
`${fixtureDescriptor}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await putContentMerkleNode(cas, "y");
const h = await cas.put( "y");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "y" };
}
@@ -15,9 +15,7 @@ import { addCliArgs } from "./bundle-fixture.js";
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
/** Three-role workflow that respects `input.steps` for fork/resume. */
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
export const descriptor = {
const threeRoleBundleSource = `export const descriptor = {
description: "fork-cli",
roles: {
planner: { description: "planner", schema: {} },
@@ -30,16 +28,16 @@ export const run = async function* (input, options) {
const cas = options.cas;
const has = (r) => input.steps.some((s) => s.role === r);
if (!has("planner")) {
const h = await putContentMerkleNode(cas, "p1");
const h = await cas.put( "p1");
yield { role: "planner", contentHash: h, meta: { k: "planner" }, refs: [h] };
}
if (!has("coder")) {
const h = await putContentMerkleNode(cas, "c1");
const h = await cas.put( "c1");
yield { role: "coder", contentHash: h, meta: { k: "coder" }, refs: [h] };
}
if (!has("reviewer")) {
const body = "rev-" + String(input.steps.length);
const h = await putContentMerkleNode(cas, body);
const h = await cas.put( body);
yield { role: "reviewer", contentHash: h, meta: { k: "reviewer" }, refs: [h] };
}
return { returnCode: 0, summary: "done" };
@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
cmdJsonCasInit,
cmdNodeGet,
cmdNodeList,
cmdNodeWalk,
cmdWorkflowRegister,
cmdWorkflowShow,
formatNodeWalk,
formatWorkflowShow,
getJsonCasDir,
} from "../src/commands/json-cas/index.js";
const SIMPLE_WORKFLOW = {
name: "test-workflow",
description: "A test workflow for CLI tests",
roles: {
analyst: {
description: "Analyses the input",
systemPrompt: "You are an analyst.",
extractPrompt: "Extract the analysis.",
schema: { type: "object", properties: { result: { type: "string" } } },
},
},
moderator: [{ from: "analyst", to: "__end__", when: null }],
};
describe("json-cas CLI commands", () => {
let storageRoot: string;
beforeEach(async () => {
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-json-cas-"));
});
afterEach(async () => {
await rm(storageRoot, { recursive: true, force: true });
});
test("getJsonCasDir returns path under storageRoot", () => {
const dir = getJsonCasDir(storageRoot);
expect(dir).toBe(join(storageRoot, "json-cas"));
});
test("init bootstraps the store and returns a workflow type hash", async () => {
const workflowTypeHash = await cmdJsonCasInit(storageRoot);
expect(typeof workflowTypeHash).toBe("string");
expect(workflowTypeHash.length).toBe(13);
});
test("init is idempotent", async () => {
const hash1 = await cmdJsonCasInit(storageRoot);
const hash2 = await cmdJsonCasInit(storageRoot);
expect(hash1).toBe(hash2);
});
test("workflow register returns a hash", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const result = await cmdWorkflowRegister(storageRoot, filePath);
expect(typeof result.hash).toBe("string");
expect(result.hash.length).toBe(13);
});
test("workflow register is idempotent", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const r1 = await cmdWorkflowRegister(storageRoot, filePath);
const r2 = await cmdWorkflowRegister(storageRoot, filePath);
expect(r1.hash).toBe(r2.hash);
});
test("workflow show loads a registered workflow", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
const wf = await cmdWorkflowShow(storageRoot, hash);
expect(wf).not.toBeNull();
if (wf === null) return;
expect(wf.name).toBe("test-workflow");
expect(wf.description).toBe("A test workflow for CLI tests");
expect(Object.keys(wf.roles)).toContain("analyst");
expect(wf.roles.analyst.systemPrompt).toBe("You are an analyst.");
expect(wf.moderator).toHaveLength(1);
expect(wf.moderator[0].from).toBe("analyst");
});
test("workflow show returns null for unknown hash", async () => {
await cmdJsonCasInit(storageRoot);
const result = await cmdWorkflowShow(storageRoot, "AAAAAAAAAAAAA");
expect(result).toBeNull();
});
test("formatWorkflowShow produces expected output", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
const wf = await cmdWorkflowShow(storageRoot, hash);
if (wf === null) throw new Error("workflow not found");
const output = formatWorkflowShow(hash, wf);
expect(output).toContain("test-workflow");
expect(output).toContain(hash);
expect(output).toContain("analyst");
expect(output).toContain("moderator:");
});
test("node list returns hashes after registration", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
await cmdWorkflowRegister(storageRoot, filePath);
const hashes = await cmdNodeList(storageRoot);
expect(hashes.length).toBeGreaterThan(0);
});
test("node list returns empty array for empty store", async () => {
const hashes = await cmdNodeList(storageRoot);
expect(hashes).toEqual([]);
});
test("node get returns JSON for a known hash", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
const json = await cmdNodeGet(storageRoot, hash);
expect(json).not.toBeNull();
if (json === null) return;
const parsed = JSON.parse(json) as unknown;
expect(parsed).toMatchObject({ payload: expect.anything() });
});
test("node get returns null for unknown hash", async () => {
const result = await cmdNodeGet(storageRoot, "AAAAAAAAAAAAA");
expect(result).toBeNull();
});
test("node walk traverses the workflow DAG", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
const entries = await cmdNodeWalk(storageRoot, hash);
expect(entries).not.toBeNull();
if (entries === null) return;
// should include at least the workflow node, role node, and role-schema node
expect(entries.length).toBeGreaterThanOrEqual(3);
const hashes = entries.map((e) => e.hash);
expect(hashes).toContain(hash);
});
test("node walk returns null for unknown root", async () => {
const result = await cmdNodeWalk(storageRoot, "AAAAAAAAAAAAA");
expect(result).toBeNull();
});
test("formatNodeWalk produces output with node hashes", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
const entries = await cmdNodeWalk(storageRoot, hash);
if (entries === null) throw new Error("walk failed");
const output = formatNodeWalk(hash, entries);
expect(output).toContain(`walk from: ${hash}`);
expect(output).toContain(hash);
});
});
@@ -23,9 +23,6 @@ import { resolveThreadRecord } from "../src/thread-scan.js";
import { addCliArgs } from "./bundle-fixture.js";
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
`;
const threadFixtureDescriptor = `export const descriptor = {
description: "thread-cli",
roles: {
@@ -41,25 +38,23 @@ const threadFixtureDescriptor = `export const descriptor = {
`;
const fastBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (input, options) {
const cas = options.cas;
let h = await putContentMerkleNode(cas, "plan");
let h = await cas.put( "plan");
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
h = await putContentMerkleNode(cas, "code");
h = await cas.put( "code");
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
return { returnCode: 0, summary: "done" };
};
`;
const slowPlannerBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (input, options) {
await new Promise((r) => setTimeout(r, 400));
const cas = options.cas;
let h = await putContentMerkleNode(cas, "plan");
let h = await cas.put( "plan");
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
h = await putContentMerkleNode(cas, "code");
h = await cas.put( "code");
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
return { returnCode: 0, summary: "done" };
};
@@ -68,37 +63,34 @@ export const run = async function* (input, options) {
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (input, options) {
const cas = options.cas;
let h = await putContentMerkleNode(cas, "plan");
let h = await cas.put( "plan");
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
await new Promise((r) => setTimeout(r, 10000));
h = await putContentMerkleNode(cas, "code");
h = await cas.put( "code");
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
return { returnCode: 0, summary: "done" };
};
`;
const pauseResumeBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (_input, options) {
const cas = options.cas;
let h = await putContentMerkleNode(cas, "f");
let h = await cas.put( "f");
yield { role: "first", contentHash: h, meta: {}, refs: [h] };
await new Promise((r) => setTimeout(r, 1500));
h = await putContentMerkleNode(cas, "s");
h = await cas.put( "s");
yield { role: "second", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "done" };
};
`;
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (_input, options) {
await new Promise((r) => setTimeout(r, 900));
const cas = options.cas;
const h = await putContentMerkleNode(cas, "x");
const h = await cas.put( "x");
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "done" };
};
+7 -3
View File
@@ -11,13 +11,17 @@
"uncaged-workflow": "src/cli.ts"
},
"dependencies": {
"@uncaged/workflow-gateway": "workspace:^",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/json-cas": "^0.1.1",
"@uncaged/json-cas-fs": "^0.1.1",
"@uncaged/json-cas-workflow": "^0.1.1",
"@uncaged/workflow-cas": "workspace:^",
"@uncaged/workflow-execute": "workspace:^",
"@uncaged/workflow-gateway": "workspace:^",
"@uncaged/workflow-json-def": "workspace:*",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-register": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"hono": "^4.12.18",
"yaml": "^2.8.4"
},
@@ -5,6 +5,7 @@ import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
import { createCasDispatcher } from "./commands/cas/index.js";
import { dispatchConnect } from "./commands/connect/index.js";
import { createInitDispatcher } from "./commands/init/index.js";
import { createJsonCasDispatcher } from "./commands/json-cas/index.js";
import { dispatchSetup } from "./commands/setup/index.js";
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
@@ -43,6 +44,7 @@ const dispatchWorkflow = createWorkflowDispatcher({ dispatchGroup });
const dispatchThread = createThreadDispatcher({ dispatchGroup });
const dispatchCas = createCasDispatcher({ dispatchGroup });
const dispatchInit = createInitDispatcher({ dispatchGroup });
const dispatchJsonCas = createJsonCasDispatcher({ dispatchGroup });
async function showSkillDocOrIndex(topic: string | undefined): Promise<number> {
if (topic === undefined) {
@@ -72,6 +74,7 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
run: dispatchRun,
live: dispatchLive,
connect: dispatchConnect,
"json-cas": dispatchJsonCas,
};
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
@@ -2,6 +2,7 @@ import type { CommandGroup } from "./cli-command-types.js";
import { setCommandGroupsForUsage } from "./cli-usage-context.js";
import { CAS_SUBCOMMAND_TABLE } from "./commands/cas/index.js";
import { INIT_SUBCOMMAND_TABLE } from "./commands/init/index.js";
import { JSON_CAS_SUBCOMMAND_TABLE } from "./commands/json-cas/index.js";
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/index.js";
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/index.js";
@@ -52,6 +53,14 @@ export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
name: "setup",
commands: [...SETUP_USAGE_COMMANDS],
},
{
name: "json-cas",
commands: Object.entries(JSON_CAS_SUBCOMMAND_TABLE).map(([name, e]) => ({
name,
args: e.args,
description: e.description,
})),
},
];
}
+1
View File
@@ -13,6 +13,7 @@ const USAGE_SECTION_BY_GROUP: Record<string, string> = {
cas: "Content-addressable storage:",
init: "Development:",
setup: "Configuration:",
"json-cas": "JSON-CAS engine:",
};
export function formatUsageCommandLines(
@@ -51,7 +51,6 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
description: "Says hello — replace with your first role.",
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
schema: greeterMetaSchema,
extractRefs: null,
};
`;
}
@@ -0,0 +1,227 @@
import type { CommandEntry } from "../../cli-command-types.js";
import { printCliError, printCliLine } from "../../cli-output.js";
import { cmdJsonCasInit } from "./init.js";
import { cmdNodeGet } from "./node-get.js";
import { cmdNodeList } from "./node-list.js";
import { cmdNodeWalk, formatNodeWalk } from "./node-walk.js";
import { getJsonCasDir } from "./store.js";
import { cmdThreadShow, formatThreadShow } from "./thread-show.js";
import type { JsonCasDispatchDeps } from "./types.js";
import { cmdWorkflowRegister } from "./workflow-register.js";
import { cmdWorkflowShow, formatWorkflowShow } from "./workflow-show.js";
// ── node subcommands ─────────────────────────────────────────────────────────
export async function dispatchNodeGet(storageRoot: string, argv: string[]): Promise<number> {
const hash = argv[0];
if (hash === undefined || argv.length > 1) {
printCliError("error: json-cas node get requires <hash>");
return 1;
}
const result = await cmdNodeGet(storageRoot, hash);
if (result === null) {
printCliError(`error: node not found: ${hash}`);
return 1;
}
printCliLine(result);
return 0;
}
export async function dispatchNodeList(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length > 0) {
printCliError("error: json-cas node list takes no arguments");
return 1;
}
const hashes = await cmdNodeList(storageRoot);
if (hashes.length === 0) {
printCliLine("(no nodes)");
return 0;
}
for (const hash of hashes) {
printCliLine(hash);
}
return 0;
}
export async function dispatchNodeWalk(storageRoot: string, argv: string[]): Promise<number> {
const hash = argv[0];
if (hash === undefined || argv.length > 1) {
printCliError("error: json-cas node walk requires <hash>");
return 1;
}
const entries = await cmdNodeWalk(storageRoot, hash);
if (entries === null) {
printCliError(`error: node not found: ${hash}`);
return 1;
}
printCliLine(formatNodeWalk(hash, entries));
return 0;
}
export const JSON_CAS_NODE_TABLE: Record<string, CommandEntry> = {
get: { handler: dispatchNodeGet, args: "<hash>", description: "Get a CAS node as JSON" },
list: { handler: dispatchNodeList, args: "", description: "List all hashes in the store" },
walk: {
handler: dispatchNodeWalk,
args: "<hash>",
description: "Walk the DAG from a node, show referenced nodes",
},
};
// ── workflow subcommands ─────────────────────────────────────────────────────
export async function dispatchWorkflowRegister(
storageRoot: string,
argv: string[],
): Promise<number> {
const file = argv[0];
if (file === undefined || argv.length > 1) {
printCliError("error: json-cas workflow register requires <file.json>");
return 1;
}
const result = await cmdWorkflowRegister(storageRoot, file);
printCliLine(`registered workflow: ${result.hash}`);
return 0;
}
export async function dispatchWorkflowShow(storageRoot: string, argv: string[]): Promise<number> {
const hash = argv[0];
if (hash === undefined || argv.length > 1) {
printCliError("error: json-cas workflow show requires <hash>");
return 1;
}
const wf = await cmdWorkflowShow(storageRoot, hash);
if (wf === null) {
printCliError(`error: workflow not found: ${hash}`);
return 1;
}
printCliLine(formatWorkflowShow(hash, wf));
return 0;
}
export const JSON_CAS_WORKFLOW_TABLE: Record<string, CommandEntry> = {
register: {
handler: dispatchWorkflowRegister,
args: "<file.json>",
description: "Register a workflow definition from a JSON file",
},
show: {
handler: dispatchWorkflowShow,
args: "<hash>",
description: "Show a workflow by its CAS hash",
},
};
// ── thread subcommands ───────────────────────────────────────────────────────
export async function dispatchThreadShow(storageRoot: string, argv: string[]): Promise<number> {
const hash = argv[0];
if (hash === undefined || argv.length > 1) {
printCliError("error: json-cas thread show requires <start-hash>");
return 1;
}
const result = await cmdThreadShow(storageRoot, hash);
if (result === null) {
printCliError(`error: thread start node not found: ${hash}`);
return 1;
}
printCliLine(formatThreadShow(result));
return 0;
}
export const JSON_CAS_THREAD_TABLE: Record<string, CommandEntry> = {
show: {
handler: dispatchThreadShow,
args: "<start-hash>",
description: "Walk and display thread steps from a thread-start hash",
},
};
// ── top-level json-cas subcommands ───────────────────────────────────────────
export async function dispatchJsonCasInit(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length > 0) {
printCliError("error: json-cas init takes no arguments");
return 1;
}
const workflowTypeHash = await cmdJsonCasInit(storageRoot);
const dir = getJsonCasDir(storageRoot);
printCliLine(`initialized json-cas store at ${dir}`);
printCliLine(`workflow type hash: ${workflowTypeHash}`);
return 0;
}
function printSubgroupHelp(
groupPath: string,
table: Record<string, CommandEntry>,
sub: string | undefined,
): number {
if (sub === undefined || sub === "--help" || sub === "-h") {
const lines = [`json-cas ${groupPath} subcommands:\n`];
for (const [name, e] of Object.entries(table)) {
const args = e.args ? ` ${e.args}` : "";
lines.push(` uncaged-workflow json-cas ${groupPath} ${name}${args}`);
lines.push(` ${e.description}\n`);
}
printCliLine(lines.join("\n"));
return sub === undefined ? 1 : 0;
}
return -1;
}
async function dispatchSubgroup(
groupPath: string,
table: Record<string, CommandEntry>,
storageRoot: string,
argv: string[],
): Promise<number> {
const sub = argv[0];
const helpResult = printSubgroupHelp(groupPath, table, sub);
if (helpResult >= 0) return helpResult;
const entry = table[sub as string];
if (entry === undefined) {
printCliError(`error: unknown json-cas ${groupPath} subcommand: ${sub}`);
return 1;
}
return entry.handler(storageRoot, argv.slice(1));
}
export const JSON_CAS_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
init: {
handler: dispatchJsonCasInit,
args: "",
description: "Initialize json-cas store and register workflow schemas",
},
workflow: {
handler: (storageRoot, argv) =>
dispatchSubgroup("workflow", JSON_CAS_WORKFLOW_TABLE, storageRoot, argv),
args: "<register|show>",
description: "Manage json-cas workflow definitions",
},
thread: {
handler: (storageRoot, argv) =>
dispatchSubgroup("thread", JSON_CAS_THREAD_TABLE, storageRoot, argv),
args: "<show>",
description: "Inspect json-cas thread execution records",
},
node: {
handler: (storageRoot, argv) =>
dispatchSubgroup("node", JSON_CAS_NODE_TABLE, storageRoot, argv),
args: "<get|list|walk>",
description: "Low-level access to json-cas store nodes",
},
};
export function createJsonCasDispatcher(deps: JsonCasDispatchDeps) {
const { dispatchGroup } = deps;
return async function dispatchJsonCas(storageRoot: string, argv: string[]): Promise<number> {
const result = dispatchGroup("json-cas", JSON_CAS_SUBCOMMAND_TABLE, storageRoot, argv);
if (result !== null) {
return result;
}
const sub = argv[0];
printCliError(`error: unknown json-cas subcommand: ${sub}`);
return 1;
};
}
@@ -0,0 +1,25 @@
export {
createJsonCasDispatcher,
dispatchJsonCasInit,
dispatchNodeGet,
dispatchNodeList,
dispatchNodeWalk,
dispatchThreadShow,
dispatchWorkflowRegister,
dispatchWorkflowShow,
JSON_CAS_NODE_TABLE,
JSON_CAS_SUBCOMMAND_TABLE,
JSON_CAS_THREAD_TABLE,
JSON_CAS_WORKFLOW_TABLE,
} from "./dispatch.js";
export { cmdJsonCasInit } from "./init.js";
export { cmdNodeGet } from "./node-get.js";
export { cmdNodeList } from "./node-list.js";
export { cmdNodeWalk, formatNodeWalk } from "./node-walk.js";
export { getJsonCasDir, openStore } from "./store.js";
export type { ThreadShowResult, ThreadStep } from "./thread-show.js";
export { cmdThreadShow, formatThreadShow } from "./thread-show.js";
export type { JsonCasDispatchDeps } from "./types.js";
export type { WorkflowRegisterResult } from "./workflow-register.js";
export { cmdWorkflowRegister } from "./workflow-register.js";
export { cmdWorkflowShow, formatWorkflowShow } from "./workflow-show.js";
@@ -0,0 +1,6 @@
import { openStore } from "./store.js";
export async function cmdJsonCasInit(storageRoot: string): Promise<string> {
const { typeHashes } = await openStore(storageRoot);
return typeHashes.workflow;
}
@@ -0,0 +1,9 @@
import { createFsStore } from "@uncaged/json-cas-fs";
import { getJsonCasDir } from "./store.js";
export async function cmdNodeGet(storageRoot: string, hash: string): Promise<string | null> {
const store = createFsStore(getJsonCasDir(storageRoot));
const node = store.get(hash);
if (node === null) return null;
return JSON.stringify(node, null, 2);
}
@@ -0,0 +1,7 @@
import { createFsStore } from "@uncaged/json-cas-fs";
import { getJsonCasDir } from "./store.js";
export async function cmdNodeList(storageRoot: string): Promise<string[]> {
const store = createFsStore(getJsonCasDir(storageRoot));
return store.list();
}
@@ -0,0 +1,50 @@
import type { CasNode, Hash } from "@uncaged/json-cas";
import { walk } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import { getJsonCasDir } from "./store.js";
export type WalkEntry = {
hash: Hash;
type: Hash;
timestamp: number;
payloadPreview: string;
};
export async function cmdNodeWalk(
storageRoot: string,
rootHash: string,
): Promise<WalkEntry[] | null> {
const store = createFsStore(getJsonCasDir(storageRoot));
if (!store.has(rootHash)) return null;
const entries: WalkEntry[] = [];
walk(store, rootHash, (hash: Hash, node: CasNode) => {
const preview = JSON.stringify(node.payload).slice(0, 100);
entries.push({
hash,
type: node.type,
timestamp: node.timestamp,
payloadPreview: preview,
});
});
return entries;
}
export function formatNodeWalk(rootHash: string, entries: WalkEntry[]): string {
const lines: string[] = [];
lines.push(`walk from: ${rootHash}`);
lines.push(`nodes: ${entries.length}`);
for (const entry of entries) {
lines.push("");
lines.push(` hash: ${entry.hash}`);
lines.push(` type: ${entry.type}`);
lines.push(` time: ${new Date(entry.timestamp).toISOString()}`);
lines.push(` data: ${entry.payloadPreview}`);
}
return lines.join("\n");
}
@@ -0,0 +1,23 @@
import { join } from "node:path";
import type { Store } from "@uncaged/json-cas";
import { bootstrap } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { WorkflowSchemaHashes } from "@uncaged/json-cas-workflow";
import { registerWorkflowSchemas } from "@uncaged/json-cas-workflow";
export function getJsonCasDir(storageRoot: string): string {
return join(storageRoot, "json-cas");
}
export type OpenStoreResult = {
store: Store;
typeHashes: WorkflowSchemaHashes;
};
export async function openStore(storageRoot: string): Promise<OpenStoreResult> {
const dir = getJsonCasDir(storageRoot);
const store = createFsStore(dir);
await bootstrap(store);
const typeHashes = await registerWorkflowSchemas(store);
return { store, typeHashes };
}
@@ -0,0 +1,116 @@
import type { Hash, Store } from "@uncaged/json-cas";
import type {
ContentPayload,
ThreadStepPayload,
WorkflowSchemaHashes,
} from "@uncaged/json-cas-workflow";
import { openStore } from "./store.js";
type StepEntry = {
hash: Hash;
payload: ThreadStepPayload;
};
function collectStepsInOrder(
store: Store,
typeHashes: WorkflowSchemaHashes,
startHash: Hash,
): StepEntry[] {
const stepMap = new Map<Hash, StepEntry>();
for (const hash of store.list()) {
const node = store.get(hash);
if (node === null || node.type !== typeHashes.threadStep) continue;
const p = node.payload as ThreadStepPayload;
if (p.start === startHash) {
stepMap.set(hash, { hash, payload: p });
}
}
if (stepMap.size === 0) return [];
// Find the terminal step: the one whose hash is not referenced as `previous` by any other step
const previousSet = new Set<Hash>();
for (const entry of stepMap.values()) {
if (entry.payload.previous !== null) {
previousSet.add(entry.payload.previous);
}
}
const terminalEntries = [...stepMap.values()].filter((e) => !previousSet.has(e.hash));
if (terminalEntries.length === 0) return [...stepMap.values()];
// Walk backward from terminal to build ordered list
const ordered: StepEntry[] = [];
let current: StepEntry | undefined = terminalEntries[0];
while (current !== undefined) {
ordered.unshift(current);
const prevHash = current.payload.previous;
if (prevHash === null) break;
current = stepMap.get(prevHash);
}
return ordered;
}
function getContentText(store: Store, contentHash: Hash): string {
const node = store.get(contentHash);
if (node === null) return "(not found)";
const payload = node.payload as ContentPayload;
return typeof payload.text === "string" ? payload.text : "(no text)";
}
export type ThreadStep = {
hash: Hash;
role: string;
meta: Record<string, unknown>;
contentPreview: string;
};
export type ThreadShowResult = {
startHash: Hash;
steps: ThreadStep[];
};
export async function cmdThreadShow(
storageRoot: string,
startHash: string,
): Promise<ThreadShowResult | null> {
const { store, typeHashes } = await openStore(storageRoot);
const startNode = store.get(startHash);
if (startNode === null) return null;
const entries = collectStepsInOrder(store, typeHashes, startHash);
const steps: ThreadStep[] = entries.map((entry) => {
const rawText = getContentText(store, entry.payload.content);
const preview = rawText.slice(0, 120).replace(/\n/g, " ");
return {
hash: entry.hash,
role: entry.payload.role,
meta: entry.payload.meta,
contentPreview: preview,
};
});
return { startHash, steps };
}
export function formatThreadShow(result: ThreadShowResult): string {
const lines: string[] = [];
lines.push(`thread: ${result.startHash}`);
lines.push(`steps: ${result.steps.length}`);
for (let i = 0; i < result.steps.length; i++) {
const step = result.steps[i];
lines.push("");
lines.push(` [${i + 1}] ${step.role} (${step.hash})`);
if (Object.keys(step.meta).length > 0) {
lines.push(` meta: ${JSON.stringify(step.meta)}`);
}
lines.push(` > ${step.contentPreview}`);
}
return lines.join("\n");
}
@@ -0,0 +1,5 @@
import type { DispatchGroupFn } from "../../cli-command-types.js";
export type JsonCasDispatchDeps = {
dispatchGroup: DispatchGroupFn;
};
@@ -0,0 +1,21 @@
import { readFileSync } from "node:fs";
import type { WorkflowInput } from "@uncaged/workflow-json-def";
import { registerWorkflow } from "@uncaged/workflow-json-def";
import { openStore } from "./store.js";
export type WorkflowRegisterResult = {
hash: string;
};
export async function cmdWorkflowRegister(
storageRoot: string,
filePath: string,
): Promise<WorkflowRegisterResult> {
const raw = readFileSync(filePath, "utf-8");
const workflowDef = JSON.parse(raw) as WorkflowInput;
const { store, typeHashes } = await openStore(storageRoot);
const hash = await registerWorkflow(store, typeHashes, workflowDef);
return { hash };
}
@@ -0,0 +1,38 @@
import type { HydratedWorkflow } from "@uncaged/workflow-json-def";
import { loadWorkflow } from "@uncaged/workflow-json-def";
import { openStore } from "./store.js";
export async function cmdWorkflowShow(
storageRoot: string,
hash: string,
): Promise<HydratedWorkflow | null> {
const { store, typeHashes } = await openStore(storageRoot);
return loadWorkflow(store, typeHashes, hash);
}
export function formatWorkflowShow(hash: string, wf: HydratedWorkflow): string {
const lines: string[] = [];
lines.push(`workflow: ${wf.name}`);
lines.push(`hash: ${hash}`);
lines.push(`desc: ${wf.description}`);
lines.push("");
lines.push("roles:");
for (const [name, role] of Object.entries(wf.roles)) {
lines.push(` ${name}:`);
lines.push(` description: ${role.description}`);
lines.push(` systemPrompt: ${role.systemPrompt.slice(0, 80).replace(/\n/g, " ")}...`);
lines.push(` extractPrompt: ${role.extractPrompt.slice(0, 80).replace(/\n/g, " ")}...`);
}
if (wf.moderator.length > 0) {
lines.push("");
lines.push("moderator:");
for (const rule of wf.moderator) {
const when = rule.when === null ? "(always)" : `when: ${rule.when}`;
lines.push(` ${rule.from}${rule.to} [${when}]`);
}
}
return lines.join("\n");
}
+1 -2
View File
@@ -249,8 +249,7 @@ Each role has:
|-------|------|---------|
| \`description\` | string | What the role does |
| \`systemPrompt\` | string | System prompt for the agent |
| \`schema\` | ZodSchema | Validates the extracted meta |
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
| \`schema\` | ZodSchema | Validates meta; annotate CAS hash strings with \`.meta({ casRef: true })\` for DAG linking |
## Development Workflow
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@uncaged/uwf-agent-hermes",
"version": "0.1.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"bin": {
"uwf-hermes": "./src/cli.ts"
},
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/uwf-agent-kit": "workspace:^"
},
"devDependencies": {
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
}
}
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bun
import { createHermesAgent } from "./hermes.js";
const main = createHermesAgent();
void main();
+90
View File
@@ -0,0 +1,90 @@
import { spawn } from "node:child_process";
import { type AgentContext, createAgent } from "@uncaged/uwf-agent-kit";
const HERMES_COMMAND = "hermes";
const HERMES_MAX_TURNS = 90;
function buildHistorySummary(history: AgentContext["history"]): string {
if (history.length === 0) {
return "";
}
const lines: string[] = ["## Previous Steps"];
for (let i = 0; i < history.length; i++) {
const step = history[i];
if (step === undefined) {
continue;
}
lines.push("");
lines.push(`### Step ${i + 1}: ${step.role}`);
lines.push(`Output: ${JSON.stringify(step.output)}`);
lines.push(`Agent: ${step.agent}`);
}
return lines.join("\n");
}
/** Assemble system prompt, task, and prior step outputs for Hermes. */
export function buildHermesPrompt(ctx: AgentContext): string {
const parts: string[] = [ctx.systemPrompt, "", "## Task", ctx.prompt];
const historyBlock = buildHistorySummary(ctx.history);
if (historyBlock !== "") {
parts.push("", historyBlock);
}
return parts.join("\n");
}
function spawnHermesChat(prompt: string): Promise<string> {
return new Promise((resolve, reject) => {
const args = [
"chat",
"-q",
prompt,
"--yolo",
"--max-turns",
String(HERMES_MAX_TURNS),
"--quiet",
];
const child = spawn(HERMES_COMMAND, args, {
env: process.env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on("error", (cause) => {
const message = cause instanceof Error ? cause.message : String(cause);
reject(new Error(`hermes spawn failed: ${message}`));
});
child.on("close", (code) => {
if (code === 0) {
resolve(stdout);
return;
}
const detail = stderr.trim() !== "" ? ` stderr=${stderr.trim()}` : "";
reject(new Error(`hermes exited with code ${code ?? "null"}${detail}`));
});
});
}
async function runHermes(ctx: AgentContext): Promise<string> {
const fullPrompt = buildHermesPrompt(ctx);
return spawnHermesChat(fullPrompt);
}
/** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */
export function createHermesAgent(): () => Promise<void> {
return createAgent({
name: "hermes",
run: runHermes,
});
}
+1
View File
@@ -0,0 +1 @@
export { buildHermesPrompt, createHermesAgent } from "./hermes.js";
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [{ "path": "../uwf-agent-kit" }]
}
@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test";
import type { WorkflowConfig } from "@uncaged/uwf-protocol";
import { resolveExtractModelAlias } from "../src/extract.js";
function baseConfig(overrides: Partial<WorkflowConfig> = {}): WorkflowConfig {
return {
providers: {},
models: {
sonnet: { provider: "openrouter", name: "anthropic/claude-sonnet-4" },
"gpt4o-mini": { provider: "openai", name: "gpt-4o-mini" },
},
agents: {},
defaultAgent: "hermes",
agentOverrides: null,
defaultModel: "sonnet",
modelOverrides: null,
...overrides,
};
}
describe("resolveExtractModelAlias", () => {
test("uses modelOverrides.extract when set", () => {
const config = baseConfig({
modelOverrides: { extract: "gpt4o-mini" },
});
expect(resolveExtractModelAlias(config)).toBe("gpt4o-mini");
});
test("falls back to models.extract alias when present", () => {
const config = baseConfig({
models: {
extract: { provider: "openai", name: "gpt-4o-mini" },
sonnet: { provider: "openrouter", name: "anthropic/claude-sonnet-4" },
},
});
expect(resolveExtractModelAlias(config)).toBe("extract");
});
test("falls back to defaultModel", () => {
expect(resolveExtractModelAlias(baseConfig())).toBe("sonnet");
});
});
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@uncaged/uwf-agent-kit",
"version": "0.1.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "^0.1.3",
"@uncaged/json-cas-fs": "^0.1.2",
"@uncaged/uwf-protocol": "workspace:^",
"dotenv": "^16.6.1",
"yaml": "^2.8.4"
},
"devDependencies": {
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
}
}
+199
View File
@@ -0,0 +1,199 @@
import type {
CasRef,
StartNodePayload,
StepContext,
StepNodePayload,
ThreadId,
} from "@uncaged/uwf-protocol";
import { createAgentStore, loadThreadsIndex, resolveStorageRoot } from "./storage.js";
import type { AgentContext } from "./types.js";
type ChainState = {
startHash: CasRef;
start: StartNodePayload;
stepsNewestFirst: StepNodePayload[];
headIsStart: boolean;
};
function fail(message: string): never {
throw new Error(message);
}
function walkChain(
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
schemas: Awaited<ReturnType<typeof createAgentStore>>["schemas"],
headHash: CasRef,
): ChainState {
const headNode = store.get(headHash);
if (headNode === null) {
fail(`CAS node not found: ${headHash}`);
}
if (headNode.type === schemas.startNode) {
return {
startHash: headHash,
start: headNode.payload as StartNodePayload,
stepsNewestFirst: [],
headIsStart: true,
};
}
if (headNode.type !== schemas.stepNode) {
fail(`head ${headHash} is not a StartNode or StepNode`);
}
const stepsNewestFirst: StepNodePayload[] = [];
let hash: CasRef | null = headHash;
while (hash !== null) {
const node = store.get(hash);
if (node === null) {
fail(`CAS node not found while walking chain: ${hash}`);
}
if (node.type !== schemas.stepNode) {
break;
}
const payload = node.payload as StepNodePayload;
stepsNewestFirst.push(payload);
hash = payload.prev;
}
const newest = stepsNewestFirst[0];
if (newest === undefined) {
fail(`empty step chain at head ${headHash}`);
}
const startNode = store.get(newest.start);
if (startNode === null || startNode.type !== schemas.startNode) {
fail(`StartNode not found: ${newest.start}`);
}
return {
startHash: newest.start,
start: startNode.payload as StartNodePayload,
stepsNewestFirst,
headIsStart: false,
};
}
function expandOutput(
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
outputRef: CasRef,
): unknown {
const node = store.get(outputRef);
if (node === null) {
return {};
}
return node.payload;
}
async function buildHistory(
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
stepsNewestFirst: StepNodePayload[],
): Promise<StepContext[]> {
const chronological = [...stepsNewestFirst].reverse();
const history: StepContext[] = [];
for (const step of chronological) {
history.push({
role: step.role,
output: expandOutput(store, step.output),
detail: step.detail,
agent: step.agent,
});
}
return history;
}
async function loadWorkflow(
store: Awaited<ReturnType<typeof createAgentStore>>["store"],
schemas: Awaited<ReturnType<typeof createAgentStore>>["schemas"],
workflowRef: CasRef,
) {
const node = store.get(workflowRef);
if (node === null) {
fail(`workflow CAS node not found: ${workflowRef}`);
}
if (node.type !== schemas.workflow) {
fail(`node ${workflowRef} is not a Workflow`);
}
return node.payload as AgentContext["workflow"];
}
/**
* Build agent execution context from thread head in threads.yaml.
* Walks the CAS chain from head to StartNode and expands step outputs.
*/
export async function buildContext(threadId: ThreadId, role: string): Promise<AgentContext> {
const storageRoot = resolveStorageRoot();
const agentStore = await createAgentStore(storageRoot);
const { store, schemas } = agentStore;
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId];
if (headHash === undefined) {
fail(`thread not found in threads.yaml: ${threadId}`);
}
const chain = walkChain(store, schemas, headHash);
const workflow = await loadWorkflow(store, schemas, chain.start.workflow);
const roleDef = workflow.roles[role];
if (roleDef === undefined) {
fail(`unknown role "${role}" in workflow "${workflow.name}"`);
}
const history = await buildHistory(store, chain.stepsNewestFirst);
return {
threadId,
role,
systemPrompt: roleDef.systemPrompt,
prompt: chain.start.prompt,
history,
workflow,
};
}
export type BuildContextMeta = {
storageRoot: string;
store: Awaited<ReturnType<typeof createAgentStore>>["store"];
schemas: Awaited<ReturnType<typeof createAgentStore>>["schemas"];
headHash: CasRef;
chain: ChainState;
};
/**
* Same as {@link buildContext} but also returns chain metadata for writing the next StepNode.
*/
export async function buildContextWithMeta(
threadId: ThreadId,
role: string,
): Promise<AgentContext & { meta: BuildContextMeta }> {
const storageRoot = resolveStorageRoot();
const agentStore = await createAgentStore(storageRoot);
const { store, schemas } = agentStore;
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId];
if (headHash === undefined) {
fail(`thread not found in threads.yaml: ${threadId}`);
}
const chain = walkChain(store, schemas, headHash);
const workflow = await loadWorkflow(store, schemas, chain.start.workflow);
const roleDef = workflow.roles[role];
if (roleDef === undefined) {
fail(`unknown role "${role}" in workflow "${workflow.name}"`);
}
const history = await buildHistory(store, chain.stepsNewestFirst);
return {
threadId,
role,
systemPrompt: roleDef.systemPrompt,
prompt: chain.start.prompt,
history,
workflow,
meta: { storageRoot, store, schemas, headHash, chain },
};
}
+181
View File
@@ -0,0 +1,181 @@
import { getSchema, validate } from "@uncaged/json-cas";
import type { CasRef, ModelAlias, WorkflowConfig } from "@uncaged/uwf-protocol";
import { config as loadDotenv } from "dotenv";
import { createAgentStore, getEnvPath, resolveStorageRoot } from "./storage.js";
export type ResolvedLlmProvider = {
baseUrl: string;
apiKey: string;
model: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Resolve model alias for extract: modelOverrides.extract → models.extract → defaultModel. */
export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
const fromOverride = config.modelOverrides?.extract ?? null;
if (fromOverride !== null) {
return fromOverride;
}
if (config.models.extract !== undefined) {
return "extract";
}
if (config.models.default !== undefined) {
return "default";
}
return config.defaultModel;
}
export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider {
const modelEntry = config.models[alias];
if (modelEntry === undefined) {
throw new Error(`unknown model alias: ${alias}`);
}
const providerEntry = config.providers[modelEntry.provider];
if (providerEntry === undefined) {
throw new Error(`unknown provider "${modelEntry.provider}" for model "${alias}"`);
}
const apiKey = process.env[providerEntry.apiKeyEnv];
if (apiKey === undefined || apiKey === "") {
throw new Error(`missing API key env var: ${providerEntry.apiKeyEnv}`);
}
return {
baseUrl: providerEntry.baseUrl,
apiKey,
model: modelEntry.name,
};
}
function chatUrl(baseUrl: string): string {
const trimmed = baseUrl.replace(/\/+$/, "");
return `${trimmed}/chat/completions`;
}
function extractJsonFromAssistantText(text: string): unknown {
const trimmed = text.trim();
const fenceMatch = /^```(?:json)?\s*([\s\S]*?)```$/m.exec(trimmed);
const candidate = fenceMatch !== null ? fenceMatch[1].trim() : trimmed;
return JSON.parse(candidate) as unknown;
}
function parseAssistantText(parsed: unknown): string {
if (!isRecord(parsed)) {
throw new Error("LLM response is not an object");
}
const choices = parsed.choices;
if (!Array.isArray(choices) || choices.length === 0) {
throw new Error("LLM response has no choices");
}
const c0 = choices[0];
if (!isRecord(c0)) {
throw new Error("LLM choice is not an object");
}
const messageObj = c0.message;
if (!isRecord(messageObj)) {
throw new Error("LLM message is not an object");
}
const content = messageObj.content;
if (typeof content !== "string") {
throw new Error("LLM message has no text content");
}
return content;
}
async function chatCompletionText(
provider: ResolvedLlmProvider,
messages: Array<{ role: "system" | "user"; content: string }>,
): Promise<string> {
let response: Response;
try {
response = await fetch(chatUrl(provider.baseUrl), {
method: "POST",
headers: {
Authorization: `Bearer ${provider.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: provider.model,
messages,
response_format: { type: "json_object" },
}),
});
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
throw new Error(`LLM network error: ${message}`);
}
const responseText = await response.text();
if (!response.ok) {
throw new Error(`LLM HTTP ${response.status}: ${responseText.slice(0, 2000)}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(responseText) as unknown;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
throw new Error(`LLM invalid JSON response: ${message}`);
}
return parseAssistantText(parsed);
}
export type ExtractResult = {
value: unknown;
hash: CasRef;
};
/**
* Call an OpenAI-compatible LLM to extract structured output matching outputSchema.
* Loads config.yaml and .env from the workflow storage root.
*/
export async function extract(
rawOutput: string,
outputSchema: CasRef,
config: WorkflowConfig,
): Promise<ExtractResult> {
const storageRoot = resolveStorageRoot();
loadDotenv({ path: getEnvPath(storageRoot) });
const { store } = await createAgentStore(storageRoot);
const schema = getSchema(store, outputSchema);
if (schema === null) {
throw new Error(`output schema not found in CAS: ${outputSchema}`);
}
const modelAlias = resolveExtractModelAlias(config);
const provider = resolveModel(config, modelAlias);
const schemaText = JSON.stringify(schema, null, 2);
const assistantText = await chatCompletionText(provider, [
{
role: "system",
content:
"Extract structured data from the agent output. Reply with a single JSON object only, no markdown or prose. The JSON must validate against this JSON Schema:\n" +
schemaText,
},
{
role: "user",
content: rawOutput,
},
]);
let structured: unknown;
try {
structured = extractJsonFromAssistantText(assistantText);
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
throw new Error(`failed to parse extracted JSON: ${message}`);
}
const outputHash = await store.put(outputSchema, structured);
const node = store.get(outputHash);
if (node === null || !validate(store, node)) {
throw new Error("extracted output failed JSON Schema validation");
}
return { value: structured, hash: outputHash };
}
+11
View File
@@ -0,0 +1,11 @@
export type { BuildContextMeta } from "./context.js";
export { buildContext, buildContextWithMeta } from "./context.js";
export { getConfigPath, getEnvPath, loadWorkflowConfig } from "./storage.js";
export type { ExtractResult, ResolvedLlmProvider } from "./extract.js";
export {
extract,
resolveExtractModelAlias,
resolveModel,
} from "./extract.js";
export { createAgent } from "./run.js";
export type { AgentContext, AgentOptions, AgentRunFn } from "./types.js";
+135
View File
@@ -0,0 +1,135 @@
import { validate } from "@uncaged/json-cas";
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/uwf-protocol";
import { config as loadDotenv } from "dotenv";
import { buildContextWithMeta } from "./context.js";
import { extract } from "./extract.js";
import type { AgentStore } from "./storage.js";
import { getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
import type { AgentContext, AgentOptions } from "./types.js";
function fail(message: string): never {
process.stderr.write(`${message}\n`);
process.exit(1);
}
function agentLabel(name: string): string {
if (name.startsWith("uwf-")) {
return name;
}
return `uwf-${name}`;
}
function parseArgv(argv: string[]): { threadId: ThreadId; role: string } {
const threadId = argv[2];
const role = argv[3];
if (threadId === undefined || threadId === "") {
fail("usage: <agent-cli> <thread-id> <role>");
}
if (role === undefined || role === "") {
fail("usage: <agent-cli> <thread-id> <role>");
}
return { threadId: threadId as ThreadId, role };
}
function runWithMessage<T>(label: string, fn: () => Promise<T>): Promise<T> {
return fn().catch((e: unknown) => {
const message = e instanceof Error ? e.message : String(e);
fail(`${label}: ${message}`);
});
}
async function writeStepNode(options: {
store: AgentStore["store"];
schemas: AgentStore["schemas"];
startHash: CasRef;
prevHash: CasRef | null;
role: string;
outputHash: CasRef;
detailHash: CasRef;
agentName: string;
}): Promise<CasRef> {
const payload: StepNodePayload = {
start: options.startHash,
prev: options.prevHash,
role: options.role,
output: options.outputHash,
detail: options.detailHash,
agent: options.agentName,
};
const hash = await options.store.put(options.schemas.stepNode, payload);
const node = options.store.get(hash);
if (node === null || !validate(options.store, node)) {
fail("stored StepNode failed schema validation");
}
return hash;
}
async function runAgent(options: AgentOptions, ctx: AgentContext): Promise<string> {
return runWithMessage("agent run failed", () => options.run(ctx));
}
async function extractOutput(
rawOutput: string,
outputSchema: CasRef,
storageRoot: string,
): Promise<CasRef> {
const config = await runWithMessage("failed to load config", () =>
loadWorkflowConfig(storageRoot),
);
const extracted = await runWithMessage("extract failed", () =>
extract(rawOutput, outputSchema, config),
);
return extracted.hash;
}
async function persistStep(options: {
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>;
rawOutput: string;
outputHash: CasRef;
agentName: string;
}): Promise<CasRef> {
const { store, schemas, chain, headHash } = options.ctx.meta;
const detailHash = await store.put(null, options.rawOutput);
return writeStepNode({
store,
schemas,
startHash: chain.startHash,
prevHash: chain.headIsStart ? null : headHash,
role: options.ctx.role,
outputHash: options.outputHash,
detailHash,
agentName: options.agentName,
});
}
/**
* Create an agent CLI entrypoint.
* Parses argv (`<thread-id> <role>`), runs the agent, extracts structured output,
* writes StepNode to CAS, and prints the new node hash to stdout.
*/
export function createAgent(options: AgentOptions): () => Promise<void> {
return async function main(): Promise<void> {
const { threadId, role } = parseArgv(process.argv);
const storageRoot = resolveStorageRoot();
loadDotenv({ path: getEnvPath(storageRoot) });
const ctx = await runWithMessage("context", () => buildContextWithMeta(threadId, role));
const roleDef = ctx.workflow.roles[role];
if (roleDef === undefined) {
fail(`unknown role: ${role}`);
}
const rawOutput = await runAgent(options, ctx);
const outputHash = await extractOutput(rawOutput, roleDef.outputSchema, storageRoot);
const stepHash = await persistStep({
ctx,
rawOutput,
outputHash,
agentName: agentLabel(options.name),
});
process.stdout.write(`${stepHash}\n`);
};
}
+26
View File
@@ -0,0 +1,26 @@
import type { Hash, Store } from "@uncaged/json-cas";
import { putSchema } from "@uncaged/json-cas";
import {
START_NODE_SCHEMA,
STEP_NODE_SCHEMA,
WORKFLOW_SCHEMA,
} from "@uncaged/uwf-protocol";
export type UwfAgentSchemaHashes = {
workflow: Hash;
startNode: Hash;
stepNode: Hash;
};
/**
* Register Workflow, StartNode, and StepNode JSON Schemas in the CAS store.
* Idempotent: safe to call on every agent invocation.
*/
export async function registerAgentSchemas(store: Store): Promise<UwfAgentSchemaHashes> {
const [workflow, startNode, stepNode] = await Promise.all([
putSchema(store, WORKFLOW_SCHEMA),
putSchema(store, START_NODE_SCHEMA),
putSchema(store, STEP_NODE_SCHEMA),
]);
return { workflow, startNode, stepNode };
}
+227
View File
@@ -0,0 +1,227 @@
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Store } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type {
AgentAlias,
AgentConfig,
ModelAlias,
ModelConfig,
ProviderAlias,
ProviderConfig,
Scenario,
ThreadId,
ThreadsIndex,
WorkflowConfig,
WorkflowName,
} from "@uncaged/uwf-protocol";
import { parse } from "yaml";
import { registerAgentSchemas } from "./schemas.js";
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
export function getDefaultStorageRoot(): string {
return join(homedir(), ".uncaged", "workflow");
}
/**
* Resolve storage root.
* Priority: `UNCAGED_WORKFLOW_STORAGE_ROOT` → `WORKFLOW_STORAGE_ROOT` → default.
*/
export function resolveStorageRoot(): string {
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
if (internal !== undefined && internal !== "") {
return internal;
}
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
if (userOverride !== undefined && userOverride !== "") {
return userOverride;
}
return getDefaultStorageRoot();
}
export function getCasDir(storageRoot: string): string {
return join(storageRoot, "cas");
}
export function getConfigPath(storageRoot: string): string {
return join(storageRoot, "config.yaml");
}
export function getEnvPath(storageRoot: string): string {
return join(storageRoot, ".env");
}
export function getThreadsPath(storageRoot: string): string {
return join(storageRoot, "threads.yaml");
}
export type AgentStore = {
storageRoot: string;
store: Store;
schemas: Awaited<ReturnType<typeof registerAgentSchemas>>;
};
export async function createAgentStore(storageRoot: string): Promise<AgentStore> {
const store = createFsStore(getCasDir(storageRoot));
const schemas = await registerAgentSchemas(store);
return { storageRoot, store, schemas };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeProviders(raw: unknown): Record<ProviderAlias, ProviderConfig> {
if (!isRecord(raw)) {
throw new Error("config.providers must be a mapping");
}
const providers: Record<ProviderAlias, ProviderConfig> = {};
for (const [name, entry] of Object.entries(raw)) {
if (!isRecord(entry)) {
throw new Error(`config.providers.${name} must be a mapping`);
}
const baseUrl = entry.baseUrl;
const apiKeyEnv = entry.apiKeyEnv;
if (typeof baseUrl !== "string" || typeof apiKeyEnv !== "string") {
throw new Error(`config.providers.${name} requires baseUrl and apiKeyEnv`);
}
providers[name] = { baseUrl, apiKeyEnv };
}
return providers;
}
function normalizeModels(raw: unknown): Record<ModelAlias, ModelConfig> {
if (!isRecord(raw)) {
throw new Error("config.models must be a mapping");
}
const models: Record<ModelAlias, ModelConfig> = {};
for (const [name, entry] of Object.entries(raw)) {
if (!isRecord(entry)) {
throw new Error(`config.models.${name} must be a mapping`);
}
const provider = entry.provider;
const modelName = entry.name;
if (typeof provider !== "string" || typeof modelName !== "string") {
throw new Error(`config.models.${name} requires provider and name`);
}
models[name] = { provider, name: modelName };
}
return models;
}
function normalizeAgents(raw: unknown): Record<AgentAlias, AgentConfig> {
if (!isRecord(raw)) {
throw new Error("config.agents must be a mapping");
}
const agents: Record<AgentAlias, AgentConfig> = {};
for (const [name, entry] of Object.entries(raw)) {
if (!isRecord(entry)) {
throw new Error(`config.agents.${name} must be a mapping`);
}
const command = entry.command;
const argsRaw = entry.args;
if (typeof command !== "string") {
throw new Error(`config.agents.${name} requires command`);
}
const args = Array.isArray(argsRaw)
? argsRaw.filter((a): a is string => typeof a === "string")
: [];
agents[name] = { command, args };
}
return agents;
}
function normalizeModelOverrides(raw: unknown): Record<Scenario, ModelAlias> | null {
if (raw === undefined || raw === null) {
return null;
}
if (!isRecord(raw)) {
throw new Error("config.modelOverrides must be a mapping or null");
}
const overrides: Record<Scenario, ModelAlias> = {};
for (const [scene, alias] of Object.entries(raw)) {
if (typeof alias === "string") {
overrides[scene] = alias;
}
}
return overrides;
}
function normalizeAgentOverrides(
raw: unknown,
): Record<WorkflowName, Record<string, AgentAlias>> | null {
if (raw === undefined || raw === null) {
return null;
}
if (!isRecord(raw)) {
throw new Error("config.agentOverrides must be a mapping or null");
}
const overrides: Record<WorkflowName, Record<string, AgentAlias>> = {};
for (const [workflowName, rolesRaw] of Object.entries(raw)) {
if (!isRecord(rolesRaw)) {
continue;
}
const roles: Record<string, AgentAlias> = {};
for (const [roleName, alias] of Object.entries(rolesRaw)) {
if (typeof alias === "string") {
roles[roleName] = alias;
}
}
overrides[workflowName] = roles;
}
return overrides;
}
export function normalizeWorkflowConfig(raw: unknown): WorkflowConfig {
if (!isRecord(raw)) {
throw new Error("config.yaml root must be a mapping");
}
const defaultAgent = raw.defaultAgent;
const defaultModel = raw.defaultModel;
if (typeof defaultAgent !== "string" || typeof defaultModel !== "string") {
throw new Error("config requires defaultAgent and defaultModel");
}
return {
providers: normalizeProviders(raw.providers),
models: normalizeModels(raw.models),
agents: normalizeAgents(raw.agents),
defaultAgent,
agentOverrides: normalizeAgentOverrides(raw.agentOverrides),
defaultModel,
modelOverrides: normalizeModelOverrides(raw.modelOverrides),
};
}
export async function loadWorkflowConfig(storageRoot: string): Promise<WorkflowConfig> {
const path = getConfigPath(storageRoot);
const text = await readFile(path, "utf8");
const raw = parse(text) as unknown;
return normalizeWorkflowConfig(raw);
}
export async function loadThreadsIndex(storageRoot: string): Promise<ThreadsIndex> {
const path = getThreadsPath(storageRoot);
try {
const text = await readFile(path, "utf8");
const raw = parse(text) as unknown;
if (!isRecord(raw)) {
return {};
}
const index: ThreadsIndex = {};
for (const [threadId, head] of Object.entries(raw)) {
if (typeof head === "string") {
index[threadId as ThreadId] = head;
}
}
return index;
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
return {};
}
throw e;
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { StepContext, ThreadId, WorkflowPayload } from "@uncaged/uwf-protocol";
export type AgentContext = {
threadId: ThreadId;
role: string;
systemPrompt: string;
prompt: string;
history: StepContext[];
workflow: WorkflowPayload;
};
export type AgentRunFn = (ctx: AgentContext) => Promise<string>;
export type AgentOptions = {
name: string;
run: AgentRunFn;
};
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [{ "path": "../uwf-protocol" }]
}
@@ -0,0 +1,120 @@
import { describe, expect, test } from "bun:test";
import type { ModeratorContext, WorkflowPayload } from "@uncaged/uwf-protocol";
import { evaluate } from "../src/evaluate.js";
const solveIssueWorkflow: WorkflowPayload = {
name: "solve-issue",
description: "End-to-end issue resolution",
roles: {
planner: {
description: "Creates implementation plan",
systemPrompt: "You are a planning agent...",
outputSchema: "5GWKR8TN1V3JA",
},
developer: {
description: "Implements code changes",
systemPrompt: "You are a developer agent...",
outputSchema: "8CNWT4KR6D1HV",
},
reviewer: {
description: "Reviews code changes",
systemPrompt: "You are a code reviewer...",
outputSchema: "1VPBG9SM5E7WK",
},
},
conditions: {
needsClarification: {
description: "Planner requests clarification from user",
expression: "$exists(steps[-1].output.needsClarification)",
},
notApproved: {
description: "Reviewer rejected the implementation",
expression: "steps[-1].output.approved = false",
},
},
graph: {
$START: [{ role: "planner", condition: null }],
planner: [
{ role: "developer", condition: "needsClarification" },
{ role: "$END", condition: null },
],
developer: [{ role: "reviewer", condition: null }],
reviewer: [
{ role: "developer", condition: "notApproved" },
{ role: "$END", condition: null },
],
},
};
function makeContext(steps: ModeratorContext["steps"]): ModeratorContext {
return {
start: {
workflow: "4KNM2PXR3B1QW",
prompt: "Fix the login bug",
},
steps,
};
}
describe("evaluate", () => {
test("$START → first role (fallback)", async () => {
const result = await evaluate(solveIssueWorkflow, makeContext([]));
expect(result).toEqual({ ok: true, value: "planner" });
});
test("condition match (notApproved → developer)", async () => {
const context = makeContext([
{
role: "reviewer",
output: { approved: false },
detail: "2MXBG6PN4A8JR",
agent: "uwf-hermes",
},
]);
const result = await evaluate(solveIssueWorkflow, context);
expect(result).toEqual({ ok: true, value: "developer" });
});
test("fallback when condition does not match → $END", async () => {
const context = makeContext([
{
role: "reviewer",
output: { approved: true },
detail: "2MXBG6PN4A8JR",
agent: "uwf-hermes",
},
]);
const result = await evaluate(solveIssueWorkflow, context);
expect(result).toEqual({ ok: true, value: "$END" });
});
test("missing role in graph → error", async () => {
const context = makeContext([
{
role: "unknown-role",
output: {},
detail: "2MXBG6PN4A8JR",
agent: "uwf-hermes",
},
]);
const result = await evaluate(solveIssueWorkflow, context);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
}
});
test("output expansion in context works with JSONata", async () => {
const context = makeContext([
{
role: "planner",
output: { needsClarification: true },
detail: "7BQST3VW9F2MA",
agent: "uwf-hermes",
},
]);
const result = await evaluate(solveIssueWorkflow, context);
expect(result).toEqual({ ok: true, value: "developer" });
});
});
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@uncaged/uwf-moderator",
"version": "0.1.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/uwf-protocol": "workspace:^",
"jsonata": "^1.8.7"
},
"devDependencies": {
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
}
}
+82
View File
@@ -0,0 +1,82 @@
import type { ModeratorContext, WorkflowPayload } from "@uncaged/uwf-protocol";
import jsonata from "jsonata";
import type { Result } from "./types.js";
const START_ROLE = "$START";
function isTruthy(value: unknown): boolean {
if (value === null || value === undefined) {
return false;
}
if (typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
return value !== 0 && !Number.isNaN(value);
}
if (typeof value === "string") {
return value.length > 0;
}
return true;
}
async function evaluateJsonata(expression: string, context: ModeratorContext): Promise<Result<unknown, Error>> {
try {
const result = await jsonata(expression).evaluate(context);
return { ok: true, value: result };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error : new Error(String(error)),
};
}
}
function currentRole(context: ModeratorContext): string {
if (context.steps.length === 0) {
return START_ROLE;
}
return context.steps[context.steps.length - 1].role;
}
export async function evaluate(
workflow: WorkflowPayload,
context: ModeratorContext,
): Promise<Result<string, Error>> {
const role = currentRole(context);
const transitions = workflow.graph[role];
if (transitions === undefined) {
return {
ok: false,
error: new Error(`no transitions defined for role "${role}"`),
};
}
for (const transition of transitions) {
if (transition.condition === null) {
return { ok: true, value: transition.role };
}
const conditionDef = workflow.conditions[transition.condition];
if (conditionDef === undefined) {
return {
ok: false,
error: new Error(`unknown condition "${transition.condition}"`),
};
}
const evalResult = await evaluateJsonata(conditionDef.expression, context);
if (!evalResult.ok) {
return evalResult;
}
if (isTruthy(evalResult.value)) {
return { ok: true, value: transition.role };
}
}
return {
ok: false,
error: new Error(`no transition matched for role "${role}"`),
};
}
+1
View File
@@ -0,0 +1 @@
export { evaluate } from "./evaluate.js";
+1
View File
@@ -0,0 +1 @@
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [{ "path": "../uwf-protocol" }]
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@uncaged/uwf-protocol",
"version": "0.1.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"dependencies": {
"@uncaged/json-cas-fs": "^0.1.3"
},
"devDependencies": {
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
}
}
+32
View File
@@ -0,0 +1,32 @@
export {
START_NODE_SCHEMA,
STEP_NODE_SCHEMA,
WORKFLOW_SCHEMA,
} from "./schemas.js";
export type {
AgentAlias,
AgentConfig,
CasRef,
ConditionDefinition,
ModelAlias,
ModelConfig,
ModeratorContext,
ProviderAlias,
ProviderConfig,
RoleDefinition,
RoleName,
Scenario,
StartNodePayload,
StartOutput,
StepContext,
StepNodePayload,
StepOutput,
StepRecord,
ThreadId,
ThreadListItem,
ThreadsIndex,
Transition,
WorkflowConfig,
WorkflowName,
WorkflowPayload,
} from "./types.js";
+83
View File
@@ -0,0 +1,83 @@
import type { JSONSchema } from "@uncaged/json-cas";
const ROLE_DEFINITION: JSONSchema = {
type: "object",
required: ["description", "systemPrompt", "outputSchema"],
properties: {
description: { type: "string" },
systemPrompt: { type: "string" },
outputSchema: { type: "string", format: "cas_ref" },
},
additionalProperties: false,
};
const CONDITION_DEFINITION: JSONSchema = {
type: "object",
required: ["description", "expression"],
properties: {
description: { type: "string" },
expression: { type: "string" },
},
additionalProperties: false,
};
const TRANSITION: JSONSchema = {
type: "object",
required: ["role", "condition"],
properties: {
role: { type: "string" },
condition: { anyOf: [{ type: "string" }, { type: "null" }] },
},
additionalProperties: false,
};
export const WORKFLOW_SCHEMA: JSONSchema = {
type: "object",
required: ["name", "description", "roles", "conditions", "graph"],
properties: {
name: { type: "string" },
description: { type: "string" },
roles: {
type: "object",
additionalProperties: ROLE_DEFINITION,
},
conditions: {
type: "object",
additionalProperties: CONDITION_DEFINITION,
},
graph: {
type: "object",
additionalProperties: {
type: "array",
items: TRANSITION,
},
},
},
additionalProperties: false,
};
export const START_NODE_SCHEMA: JSONSchema = {
type: "object",
required: ["workflow", "prompt"],
properties: {
workflow: { type: "string", format: "cas_ref" },
prompt: { type: "string" },
},
additionalProperties: false,
};
export const STEP_NODE_SCHEMA: JSONSchema = {
type: "object",
required: ["start", "prev", "role", "output", "detail", "agent"],
properties: {
start: { type: "string", format: "cas_ref" },
prev: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
role: { type: "string" },
output: { type: "string", format: "cas_ref" },
detail: { type: "string", format: "cas_ref" },
agent: { type: "string" },
},
additionalProperties: false,
};
+127
View File
@@ -0,0 +1,127 @@
// ── 4.1 公共类型 ────────────────────────────────────────────────────
/** CAS hash — XXH64, 13-char Crockford Base32 */
export type CasRef = string;
/** Thread ID — ULID, 26-char Crockford Base32 */
export type ThreadId = string;
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
export type StepRecord = {
role: string;
output: CasRef;
detail: CasRef;
agent: string;
};
// ── 4.2 Workflow 定义 ───────────────────────────────────────────────
export type RoleDefinition = {
description: string;
systemPrompt: string;
outputSchema: CasRef;
};
export type Transition = {
role: string;
condition: string | null;
};
export type ConditionDefinition = {
description: string;
expression: string;
};
export type WorkflowPayload = {
name: string;
description: string;
roles: Record<string, RoleDefinition>;
conditions: Record<string, ConditionDefinition>;
graph: Record<string, Transition[]>;
};
// ── 4.3 Thread 节点 ─────────────────────────────────────────────────
export type StartNodePayload = {
workflow: CasRef;
prompt: string;
};
export type StepNodePayload = StepRecord & {
start: CasRef;
prev: CasRef | null;
};
// ── 4.4 JSONata 求值上下文 ──────────────────────────────────────────
/** JSONata 上下文中的 step — output 被展开 */
export type StepContext = Omit<StepRecord, "output"> & {
output: unknown;
};
export type ModeratorContext = {
start: StartNodePayload;
steps: StepContext[];
};
// ── 4.5 CLI 输出 ────────────────────────────────────────────────────
/** uwf thread start */
export type StartOutput = {
workflow: CasRef;
thread: ThreadId;
};
/** uwf thread step / uwf thread show */
export type StepOutput = {
workflow: CasRef;
thread: ThreadId;
head: CasRef;
done: boolean;
};
/** uwf thread list */
export type ThreadListItem = {
thread: ThreadId;
workflow: CasRef;
head: CasRef;
};
// ── 4.6 配置 ────────────────────────────────────────────────────────
/** Alias types for config references */
export type AgentAlias = string;
export type ModelAlias = string;
export type ProviderAlias = string;
export type WorkflowName = string;
export type RoleName = string;
export type Scenario = string;
export type ProviderConfig = {
baseUrl: string;
apiKeyEnv: string;
};
export type ModelConfig = {
provider: ProviderAlias;
name: string;
};
export type AgentConfig = {
command: string;
args: string[];
};
/** ~/.uncaged/workflow/config.yaml */
export type WorkflowConfig = {
providers: Record<ProviderAlias, ProviderConfig>;
models: Record<ModelAlias, ModelConfig>;
agents: Record<AgentAlias, AgentConfig>;
defaultAgent: AgentAlias;
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
defaultModel: ModelAlias;
modelOverrides: Record<Scenario, ModelAlias> | null;
};
/** ~/.uncaged/workflow/threads.yaml */
export type ThreadsIndex = Record<ThreadId, CasRef>;
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/index.js";
describe("packageDescriptor", () => {
test("has the correct package name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-cursor");
});
test("has a non-empty version string", () => {
expect(typeof packageDescriptor.version).toBe("string");
expect(packageDescriptor.version.length).toBeGreaterThan(0);
});
test("capabilities is a non-empty array of strings", () => {
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
for (const cap of packageDescriptor.capabilities) {
expect(typeof cap).toBe("string");
}
});
test("configSchema is an object with type 'object'", () => {
expect(typeof packageDescriptor.configSchema).toBe("object");
expect(packageDescriptor.configSchema.type).toBe("object");
});
test("configSchema requires 'command' and 'timeout'", () => {
const required = packageDescriptor.configSchema.required as string[];
expect(required).toContain("command");
expect(required).toContain("timeout");
});
test("configSchema properties include command, model, timeout, workspace", () => {
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
expect(props).toHaveProperty("command");
expect(props).toHaveProperty("model");
expect(props).toHaveProperty("timeout");
expect(props).toHaveProperty("workspace");
});
});
@@ -11,7 +11,9 @@ import { extractWorkspacePath } from "./extract-workspace.js";
import type { CursorAgentConfig } from "./types.js";
import { validateCursorAgentConfig } from "./validate-config.js";
export { packageDescriptor } from "./package-descriptor.js";
export type { CursorAgentConfig } from "./types.js";
export { validateCursorAgentConfig } from "./validate-config.js";
function throwCursorSpawnError(error: SpawnCliError): never {
if (error.kind === "non_zero_exit") {
@@ -0,0 +1,34 @@
import type { PackageDescriptor } from "@uncaged/workflow-protocol";
/**
* Static metadata for @uncaged/workflow-agent-cursor.
* Config maps to {@link CursorAgentConfig}.
*/
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-cursor",
version: "0.5.0-alpha.4",
capabilities: ["cursor-cli", "workspace-agent"],
configSchema: {
type: "object",
required: ["command", "timeout"],
properties: {
command: {
type: "string",
description: "Absolute path to the cursor-agent CLI binary.",
},
model: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Model identifier passed to cursor-agent --model; null means auto.",
},
timeout: {
type: "number",
description: "Timeout in milliseconds; 0 means no limit.",
},
workspace: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Override workspace path; null resolves from thread context.",
},
},
additionalProperties: false,
},
};
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/package-descriptor.js";
import { createDocxDiffAgent } from "../src/agent.js";
describe("createDocxDiffAgent", () => {
test("returns an AdapterFn (function)", () => {
const agent = createDocxDiffAgent({ command: null });
expect(typeof agent).toBe("function");
});
test("AdapterFn returns a RoleFn (function)", () => {
const agent = createDocxDiffAgent({ command: null });
const roleFn = agent("", expect.anything() as never);
expect(typeof roleFn).toBe("function");
});
});
describe("packageDescriptor", () => {
test("has correct name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-docx-diff");
});
});
@@ -0,0 +1,113 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, mock, test } from "bun:test";
import { ok, err } from "@uncaged/workflow-util";
import type { SpawnCliConfig } from "@uncaged/workflow-util-agent";
import { runDocxDiff } from "../src/runner.js";
type MockSpawnResult = Awaited<ReturnType<typeof import("@uncaged/workflow-util-agent").spawnCli>>;
function makeSpawn(result: MockSpawnResult) {
return mock(async (_cmd: string, _args: string[], _opts: SpawnCliConfig) => result);
}
function tempDir(): string {
const dir = join(tmpdir(), `diff-test-${Date.now()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
describe("runDocxDiff", () => {
test("exit 0: success, returns DifferMeta JSON", async () => {
const dir = tempDir();
const sourceDocx = join(dir, "original.docx");
const modifiedDocx = join(dir, "modified.docx");
const diffDocx = join(dir, "diff.docx");
writeFileSync(sourceDocx, "");
writeFileSync(modifiedDocx, "");
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
// simulate docx-diff creating the diff file
writeFileSync(diffDocx, "");
const raw = await runDocxDiff(
{ command: "docx-diff" },
sourceDocx,
modifiedDocx,
diffDocx,
spawnFn,
);
const meta = JSON.parse(raw);
expect(meta.sourceDocx).toBe(sourceDocx);
expect(meta.modifiedDocx).toBe(modifiedDocx);
expect(meta.diffDocx).toBe(diffDocx);
expect(spawnFn.mock.calls[0][1]).toEqual([
sourceDocx,
modifiedDocx,
"--output",
"docx",
"--out-file",
diffDocx,
]);
});
test("exit 1 (changes found): treated as success", async () => {
const dir = tempDir();
const sourceDocx = join(dir, "s.docx");
const modifiedDocx = join(dir, "m.docx");
const diffDocx = join(dir, "diff.docx");
writeFileSync(sourceDocx, "");
writeFileSync(modifiedDocx, "");
writeFileSync(diffDocx, "");
const spawnFn = makeSpawn(
err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "" }) as MockSpawnResult,
);
await expect(
runDocxDiff({ command: "docx-diff" }, sourceDocx, modifiedDocx, diffDocx, spawnFn),
).resolves.toBeDefined();
});
test("exit 2: throws error", async () => {
const dir = tempDir();
const spawnFn = makeSpawn(
err({ kind: "non_zero_exit", exitCode: 2, stdout: "", stderr: "fatal error" }) as MockSpawnResult,
);
await expect(
runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn),
).rejects.toThrow("docx-diff failed");
});
test("timeout: throws error", async () => {
const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult);
await expect(
runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn),
).rejects.toThrow("timed out");
});
test("throws when diff file not created", async () => {
const dir = tempDir();
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
// do NOT create diffDocx
await expect(
runDocxDiff({ command: null }, "s.docx", "m.docx", join(dir, "missing.docx"), spawnFn),
).rejects.toThrow("diff file not found");
});
test("uses PATH docx-diff when command is null", async () => {
const dir = tempDir();
const diffDocx = join(dir, "diff.docx");
writeFileSync(diffDocx, "");
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
await runDocxDiff({ command: null }, "s.docx", "m.docx", diffDocx, spawnFn);
expect(spawnFn.mock.calls[0][0]).toBe("docx-diff");
});
});
@@ -0,0 +1,29 @@
{
"name": "@uncaged/workflow-agent-docx-diff",
"version": "0.1.0",
"files": ["src", "dist", "package.json"],
"type": "module",
"types": "src/index.ts",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-template-document": "workspace:^",
"zod": "^4.0.0"
},
"devDependencies": {
"@uncaged/workflow-util": "workspace:^"
},
"publishConfig": {
"access": "public"
}
}
@@ -0,0 +1,29 @@
import * as z from "zod/v4";
import { dirname, join } from "node:path";
import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
import type { WriterMeta } from "@uncaged/workflow-template-document";
import { runDocxDiff } from "./runner.js";
import type { DocxDiffAgentConfig } from "./types.js";
export function createDocxDiffAgent(config: DocxDiffAgentConfig): AdapterFn {
return <T>(_prompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const writerStep = ctx.steps.find((s) => s.role === "writer");
if (writerStep === undefined) throw new Error("differ: no writer step found");
const writerMeta = writerStep.meta as WriterMeta;
if (writerMeta.mode !== "edit")
throw new Error("differ: writer did not run in edit mode");
const diffDocx = join(dirname(writerMeta.outputDocx), "diff.docx");
const raw = await runDocxDiff(
config,
writerMeta.sourceDocx,
writerMeta.outputDocx,
diffDocx,
);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
@@ -0,0 +1,3 @@
export { createDocxDiffAgent } from "./agent.js";
export { packageDescriptor } from "./package-descriptor.js";
export type { DocxDiffAgentConfig } from "./types.js";
@@ -0,0 +1,17 @@
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-docx-diff",
version: "0.1.0",
capabilities: ["docx-diff-cli", "docx-diff-report"],
configSchema: {
type: "object",
properties: {
command: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Path to docx-diff CLI binary; null uses PATH.",
},
},
additionalProperties: false,
},
};
@@ -0,0 +1,47 @@
import { stat } from "node:fs/promises";
import { spawnCli } from "@uncaged/workflow-util-agent";
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
import type { DocxDiffAgentConfig } from "./types.js";
type SpawnCliFn = typeof spawnCli;
function throwSpawnError(e: SpawnCliError): never {
if (e.kind === "non_zero_exit")
throw new Error(`docx-diff failed (exit ${e.exitCode}): ${e.stderr}`);
if (e.kind === "timeout")
throw new Error("docx-diff: timed out");
throw new Error(`docx-diff: spawn failed: ${e.message}`);
}
export async function runDocxDiff(
config: DocxDiffAgentConfig,
sourceDocx: string,
modifiedDocx: string,
diffDocx: string,
spawnCliFn: SpawnCliFn = spawnCli,
): Promise<string> {
const command = config.command ?? "docx-diff";
const result = await spawnCliFn(
command,
[sourceDocx, modifiedDocx, "--output", "docx", "--out-file", diffDocx],
{ cwd: null, timeoutMs: null },
);
if (!result.ok) {
const e = result.error;
// exit 1 = changes found (normal for docx-diff)
if (e.kind === "non_zero_exit" && e.exitCode === 1) {
// fall through to file check
} else {
throwSpawnError(e);
}
}
try {
await stat(diffDocx);
} catch {
throw new Error(`docx-diff: diff file not found: ${diffDocx}`);
}
return JSON.stringify({ sourceDocx, modifiedDocx, diffDocx });
}
@@ -0,0 +1,3 @@
export type DocxDiffAgentConfig = {
command: string | null;
};
@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-runtime" },
{ "path": "../workflow-util-agent" },
{ "path": "../workflow-template-document" }
]
}
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/index.js";
describe("packageDescriptor", () => {
test("has the correct package name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-hermes");
});
test("has a non-empty version string", () => {
expect(typeof packageDescriptor.version).toBe("string");
expect(packageDescriptor.version.length).toBeGreaterThan(0);
});
test("capabilities is a non-empty array of strings", () => {
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
for (const cap of packageDescriptor.capabilities) {
expect(typeof cap).toBe("string");
}
});
test("configSchema is an object with type 'object'", () => {
expect(typeof packageDescriptor.configSchema).toBe("object");
expect(packageDescriptor.configSchema.type).toBe("object");
});
test("configSchema requires 'command'", () => {
const required = packageDescriptor.configSchema.required as string[];
expect(required).toContain("command");
});
test("configSchema properties include command, model, timeout", () => {
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
expect(props).toHaveProperty("command");
expect(props).toHaveProperty("model");
expect(props).toHaveProperty("timeout");
});
});
@@ -13,7 +13,9 @@ const HERMES_DEFAULT_MAX_TURNS = 90;
type HermesAgentOpt = { prompt: string };
export { packageDescriptor } from "./package-descriptor.js";
export type { HermesAgentConfig } from "./types.js";
export { validateHermesAgentConfig } from "./validate-config.js";
function throwHermesSpawnError(error: SpawnCliError): never {
if (error.kind === "non_zero_exit") {
@@ -0,0 +1,30 @@
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
/**
* Static metadata for @uncaged/workflow-agent-hermes.
* Config maps to {@link HermesAgentConfig}.
*/
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-hermes",
version: "0.5.0-alpha.4",
capabilities: ["hermes-cli", "yolo-mode"],
configSchema: {
type: "object",
required: ["command"],
properties: {
command: {
type: "string",
description: "Absolute path to the hermes CLI binary.",
},
model: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Model identifier passed to hermes --model; null uses the CLI default.",
},
timeout: {
anyOf: [{ type: "number" }, { type: "null" }],
description: "Timeout in milliseconds; null means no limit.",
},
},
additionalProperties: false,
},
};
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/index.js";
describe("packageDescriptor", () => {
test("has the correct package name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-llm");
});
test("has a non-empty version string", () => {
expect(typeof packageDescriptor.version).toBe("string");
expect(packageDescriptor.version.length).toBeGreaterThan(0);
});
test("capabilities is a non-empty array of strings", () => {
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
for (const cap of packageDescriptor.capabilities) {
expect(typeof cap).toBe("string");
}
});
test("configSchema is an object with type 'object'", () => {
expect(typeof packageDescriptor.configSchema).toBe("object");
expect(packageDescriptor.configSchema.type).toBe("object");
});
test("configSchema requires baseUrl, apiKey, model", () => {
const required = packageDescriptor.configSchema.required as string[];
expect(required).toContain("baseUrl");
expect(required).toContain("apiKey");
expect(required).toContain("model");
});
test("configSchema properties include baseUrl, apiKey, model", () => {
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
expect(props).toHaveProperty("baseUrl");
expect(props).toHaveProperty("apiKey");
expect(props).toHaveProperty("model");
});
});
+1
View File
@@ -4,3 +4,4 @@ export {
type LlmChatError,
type LlmMessage,
} from "./create-llm-adapter.js";
export { packageDescriptor } from "./package-descriptor.js";
@@ -0,0 +1,30 @@
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
/**
* Static metadata for @uncaged/workflow-agent-llm.
* Config maps to {@link LlmProvider}: baseUrl + apiKey + model.
*/
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-llm",
version: "0.5.0-alpha.4",
capabilities: ["llm-single-turn"],
configSchema: {
type: "object",
required: ["baseUrl", "apiKey", "model"],
properties: {
baseUrl: {
type: "string",
description: "Base URL of the OpenAI-compatible chat completions endpoint.",
},
apiKey: {
type: "string",
description: "API key for the provider.",
},
model: {
type: "string",
description: "Model identifier passed as the `model` field in the request body.",
},
},
additionalProperties: false,
},
};
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/package-descriptor.js";
import { createOfficeAgent } from "../src/agent.js";
describe("createOfficeAgent", () => {
test("returns an AdapterFn (function)", () => {
const agent = createOfficeAgent({ outputDir: "/tmp", command: null, timeout: null });
expect(typeof agent).toBe("function");
});
test("AdapterFn returns a RoleFn (function)", () => {
const agent = createOfficeAgent({ outputDir: "/tmp", command: null, timeout: null });
const roleFn = agent("", expect.anything() as never);
expect(typeof roleFn).toBe("function");
});
});
describe("packageDescriptor", () => {
test("has correct name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-office");
});
test("has outputDir in configSchema required", () => {
const schema = packageDescriptor.configSchema as { required: string[] };
expect(schema.required).toContain("outputDir");
});
});
@@ -0,0 +1,129 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, mock, test } from "bun:test";
import { ok, err } from "@uncaged/workflow-util";
import type { SpawnCliConfig } from "@uncaged/workflow-util-agent";
import { editDocument, generateDocument } from "../src/runner.js";
type MockSpawnResult = Awaited<ReturnType<typeof import("@uncaged/workflow-util-agent").spawnCli>>;
function makeSpawn(result: MockSpawnResult) {
return mock(async (_cmd: string, _args: string[], _opts: SpawnCliConfig) => result);
}
function tempDir(): string {
const dir = join(tmpdir(), `office-test-${Date.now()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
describe("generateDocument", () => {
test("calls office-agent create with correct args and returns outputDocx path", async () => {
const base = tempDir();
const spawnFn = makeSpawn(ok("agent reply") as MockSpawnResult);
// Simulate CLI creating the file
const outFile = join(base, "thread1", "output.docx");
mkdirSync(join(base, "thread1"), { recursive: true });
writeFileSync(outFile, "");
const result = await generateDocument(
{ outputDir: base, command: "office-agent", timeout: null },
"thread1",
"Write a report",
spawnFn,
);
expect(result.outputDocx).toBe(outFile);
expect(result.sourceDocx).toBeNull();
expect(spawnFn.mock.calls[0][0]).toBe("office-agent");
expect(spawnFn.mock.calls[0][1]).toEqual(["create", "Write a report", "-o", "output.docx"]);
expect(spawnFn.mock.calls[0][2].cwd).toBe(join(base, "thread1"));
});
test("uses PATH office-agent when command is null", async () => {
const base = tempDir();
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
mkdirSync(join(base, "t2"), { recursive: true });
writeFileSync(join(base, "t2", "output.docx"), "");
await generateDocument(
{ outputDir: base, command: null, timeout: null },
"t2",
"Generate",
spawnFn,
);
expect(spawnFn.mock.calls[0][0]).toBe("office-agent");
});
test("throws on non_zero_exit", async () => {
const base = tempDir();
const spawnFn = makeSpawn(
err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "error" }) as MockSpawnResult,
);
await expect(
generateDocument({ outputDir: base, command: null, timeout: null }, "t3", "fail", spawnFn),
).rejects.toThrow("office-agent failed (exit 1)");
});
test("throws on timeout", async () => {
const base = tempDir();
const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult);
await expect(
generateDocument({ outputDir: base, command: null, timeout: null }, "t4", "slow", spawnFn),
).rejects.toThrow("office-agent: timed out");
});
test("throws when output file not created", async () => {
const base = tempDir();
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
// Do NOT create output.docx
await expect(
generateDocument({ outputDir: base, command: null, timeout: null }, "t5", "no file", spawnFn),
).rejects.toThrow("output file not found");
});
});
describe("editDocument", () => {
test("copies input to original.docx and modified.docx, calls edit, returns paths", async () => {
const base = tempDir();
// Create a fake inputDocx
const inputFile = join(base, "source.docx");
writeFileSync(inputFile, "original content");
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
// Simulate CLI overwriting modified.docx
const outDir = join(base, "te1");
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, "modified.docx"), "modified content");
const result = await editDocument(
{ outputDir: base, command: "office-agent", timeout: null },
"te1",
"Edit the doc",
inputFile,
spawnFn,
);
expect(result.outputDocx).toBe(join(outDir, "modified.docx"));
expect(result.sourceDocx).toBe(join(outDir, "original.docx"));
expect(spawnFn.mock.calls[0][1]).toEqual(["edit", "modified.docx", "Edit the doc"]);
});
test("throws on spawn_failed", async () => {
const base = tempDir();
const inputFile = join(base, "src.docx");
writeFileSync(inputFile, "");
const spawnFn = makeSpawn(
err({ kind: "spawn_failed", message: "not found" }) as MockSpawnResult,
);
await expect(
editDocument({ outputDir: base, command: null, timeout: null }, "te2", "edit", inputFile, spawnFn),
).rejects.toThrow("spawn failed");
});
});
@@ -0,0 +1,25 @@
{
"name": "@uncaged/workflow-agent-office",
"version": "0.1.0",
"files": ["src", "dist", "package.json"],
"type": "module",
"types": "src/index.ts",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
},
"publishConfig": {
"access": "public"
}
}
@@ -0,0 +1,44 @@
import * as z from "zod/v4";
import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
import { createLogger } from "@uncaged/workflow-util";
import { editDocument, generateDocument } from "./runner.js";
import type { OfficeAgentConfig } from "./types.js";
const log = createLogger({ sink: { kind: "stderr" } });
type ParsedInput = { prompt: string; inputDocx: string | null };
function parseStartInput(content: string): ParsedInput {
try {
const parsed = JSON.parse(content) as Record<string, unknown>;
if (typeof parsed.prompt === "string") {
return {
prompt: parsed.prompt,
inputDocx: typeof parsed.inputDocx === "string" ? parsed.inputDocx : null,
};
}
} catch {
// not JSON — treat whole content as prompt, generate mode
}
return { prompt: content, inputDocx: null };
}
export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
return <T>(_systemPrompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const { prompt, inputDocx } = parseStartInput(ctx.start.content);
log("8FQKP3NV", `office-agent: mode=${inputDocx === null ? "generate" : "edit"} thread=${ctx.threadId}`);
let raw: string;
if (inputDocx === null) {
const result = await generateDocument(config, ctx.threadId, prompt);
raw = JSON.stringify({ mode: "generate", outputDocx: result.outputDocx, sourceDocx: null });
} else {
const result = await editDocument(config, ctx.threadId, prompt, inputDocx);
raw = JSON.stringify({ mode: "edit", outputDocx: result.outputDocx, sourceDocx: result.sourceDocx });
}
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
@@ -0,0 +1,3 @@
export { createOfficeAgent } from "./agent.js";
export { packageDescriptor } from "./package-descriptor.js";
export type { OfficeAgentConfig } from "./types.js";
@@ -0,0 +1,26 @@
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-office",
version: "0.1.0",
capabilities: ["office-agent-cli", "docx-generate", "docx-edit"],
configSchema: {
type: "object",
required: ["outputDir"],
properties: {
outputDir: {
type: "string",
description: "Root directory for workflow outputs; subdirs are created per threadId.",
},
command: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Path to office-agent CLI binary; null uses PATH.",
},
timeout: {
anyOf: [{ type: "number" }, { type: "null" }],
description: "Timeout in milliseconds; null means no limit.",
},
},
additionalProperties: false,
},
};
@@ -0,0 +1,65 @@
import { copyFile, mkdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { spawnCli } from "@uncaged/workflow-util-agent";
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
import type { OfficeAgentConfig } from "./types.js";
type SpawnCliFn = typeof spawnCli;
function throwSpawnError(e: SpawnCliError): never {
if (e.kind === "non_zero_exit")
throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`);
if (e.kind === "timeout")
throw new Error("office-agent: timed out");
throw new Error(`office-agent: spawn failed: ${e.message}`);
}
async function assertFileExists(path: string): Promise<void> {
try {
await stat(path);
} catch {
throw new Error(`office-agent: output file not found: ${path}`);
}
}
export async function generateDocument(
config: OfficeAgentConfig,
threadId: string,
prompt: string,
spawnCliFn: SpawnCliFn = spawnCli,
): Promise<{ outputDocx: string; sourceDocx: null }> {
const outputDir = join(config.outputDir, threadId);
await mkdir(outputDir, { recursive: true });
const command = config.command ?? "office-agent";
const result = await spawnCliFn(command, ["create", prompt, "-o", "output.docx"], {
cwd: outputDir,
timeoutMs: config.timeout,
});
if (!result.ok) throwSpawnError(result.error);
const outputDocx = join(outputDir, "output.docx");
await assertFileExists(outputDocx);
return { outputDocx, sourceDocx: null };
}
export async function editDocument(
config: OfficeAgentConfig,
threadId: string,
prompt: string,
inputDocx: string,
spawnCliFn: SpawnCliFn = spawnCli,
): Promise<{ outputDocx: string; sourceDocx: string }> {
const outputDir = join(config.outputDir, threadId);
await mkdir(outputDir, { recursive: true });
const originalDocx = join(outputDir, "original.docx");
const modifiedDocx = join(outputDir, "modified.docx");
await copyFile(inputDocx, originalDocx);
await copyFile(inputDocx, modifiedDocx);
const command = config.command ?? "office-agent";
const result = await spawnCliFn(command, ["edit", "modified.docx", prompt], {
cwd: outputDir,
timeoutMs: config.timeout,
});
if (!result.ok) throwSpawnError(result.error);
await assertFileExists(modifiedDocx);
return { outputDocx: modifiedDocx, sourceDocx: originalDocx };
}
@@ -0,0 +1,5 @@
export type OfficeAgentConfig = {
outputDir: string;
command: string | null;
timeout: number | null;
};
@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-runtime" },
{ "path": "../workflow-util" },
{ "path": "../workflow-util-agent" }
]
}
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/index.js";
describe("packageDescriptor", () => {
test("has the correct package name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-react");
});
test("has a non-empty version string", () => {
expect(typeof packageDescriptor.version).toBe("string");
expect(packageDescriptor.version.length).toBeGreaterThan(0);
});
test("capabilities is a non-empty array of strings", () => {
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
for (const cap of packageDescriptor.capabilities) {
expect(typeof cap).toBe("string");
}
});
test("configSchema is an object with type 'object'", () => {
expect(typeof packageDescriptor.configSchema).toBe("object");
expect(packageDescriptor.configSchema.type).toBe("object");
});
test("configSchema requires maxRounds", () => {
const required = packageDescriptor.configSchema.required as string[];
expect(required).toContain("maxRounds");
});
test("configSchema properties include maxRounds", () => {
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
expect(props).toHaveProperty("maxRounds");
});
});
@@ -1,4 +1,5 @@
export { createReactAdapter } from "./create-react-adapter.js";
export { packageDescriptor } from "./package-descriptor.js";
export type { ToolEntry, ToolHandler } from "./tools/index.js";
export { defaultToolHandler, defaultTools } from "./tools/index.js";
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
@@ -0,0 +1,25 @@
import type { PackageDescriptor } from "@uncaged/workflow-protocol";
/**
* Static metadata for @uncaged/workflow-agent-react.
*
* Config represents the serializable subset of {@link ReactAdapterConfig}.
* The `llm` function and `toolHandler` are runtime constructs and are not
* stored in the CAS agent node; only `maxRounds` is serializable.
*/
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-react",
version: "0.5.0-alpha.4",
capabilities: ["react-loop", "tool-calling"],
configSchema: {
type: "object",
required: ["maxRounds"],
properties: {
maxRounds: {
type: "number",
description: "Maximum number of LLM ↔ tool-call rounds before the loop is terminated.",
},
},
additionalProperties: false,
},
};
+8
View File
@@ -4,6 +4,14 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workflow Dashboard</title>
<script>
(function () {
var t = localStorage.getItem("theme");
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.classList.add("dark");
}
})();
</script>
</head>
<body>
<div id="root"></div>
+13 -1
View File
@@ -13,11 +13,23 @@
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-markdown": "^10.1.0",
"shiki": "^4.0.2"
"react-router": "^7.15.1",
"shiki": "^4.0.2",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.4",
@@ -0,0 +1,362 @@
import { createFilter, type Plugin } from "vite";
type LimitLineOverride = {
files: string;
maxReactFCLines: number | null;
maxFileLines: number | null;
};
type LimitLineOptions = {
maxReactFCLines: number;
maxFileLines: number;
include: RegExp;
exclude: RegExp | null;
overrides: Array<LimitLineOverride>;
};
const DEFAULT_OPTIONS: LimitLineOptions = {
maxReactFCLines: 300,
maxFileLines: 600,
include: /\.[tj]sx$/,
exclude: null,
overrides: [],
};
type ResolvedLimits = {
maxReactFCLines: number | null;
maxFileLines: number | null;
};
type ComponentInfo = {
name: string;
startLine: number;
lineCount: number;
};
const PASCAL_CASE = /^[A-Z][A-Za-z0-9]*$/;
// --- AST types (Rolldown ESTree subset) ---
type Identifier = {
type: "Identifier";
name: string;
};
type MemberExpression = {
type: "MemberExpression";
object: AstExpression;
property: Identifier;
};
type CallExpression = {
type: "CallExpression";
callee: AstExpression;
arguments: Array<AstExpression>;
};
type AstExpression = Identifier | MemberExpression | CallExpression | {
type: string;
[key: string]: unknown;
};
type VariableDeclarator = {
id: Identifier | null;
init: AstExpression | null;
};
type AstStatement = {
type: string;
id: Identifier | null;
declaration: AstStatement | null;
declarations: Array<VariableDeclarator>;
body: Array<AstStatement>;
[key: string]: unknown;
};
type AstProgram = {
type: "Program";
body: Array<AstStatement>;
};
// --- AST helpers ---
function isFunctionLike(node: AstExpression): boolean {
return node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
}
const WRAPPER_NAMES = new Set(["memo", "forwardRef", "lazy"]);
function isWrapperCall(node: AstExpression): boolean {
if (node.type !== "CallExpression") return false;
const call = node as CallExpression;
const callee = call.callee;
if (callee.type === "Identifier") {
return WRAPPER_NAMES.has((callee as Identifier).name);
}
if (callee.type === "MemberExpression") {
const member = callee as MemberExpression;
return member.property.type === "Identifier" && WRAPPER_NAMES.has(member.property.name);
}
return false;
}
function extractComponentNames(ast: AstProgram): Array<string> {
const names: Array<string> = [];
for (const node of ast.body) {
if (node.type === "FunctionDeclaration" && node.id && PASCAL_CASE.test(node.id.name)) {
names.push(node.id.name);
continue;
}
if (node.type === "ExportNamedDeclaration" && node.declaration) {
const decl = node.declaration;
if (decl.type === "FunctionDeclaration" && decl.id && PASCAL_CASE.test(decl.id.name)) {
names.push(decl.id.name);
continue;
}
if (decl.type === "VariableDeclaration") {
collectNamesFromVarDeclaration(decl, names);
continue;
}
}
if (node.type === "VariableDeclaration") {
collectNamesFromVarDeclaration(node, names);
}
}
return names;
}
function collectNamesFromVarDeclaration(node: AstStatement, names: Array<string>): void {
for (const declarator of node.declarations ?? []) {
if (!declarator.id || !PASCAL_CASE.test(declarator.id.name) || !declarator.init) continue;
const init = declarator.init;
if (isFunctionLike(init)) {
names.push(declarator.id.name);
} else if (isWrapperCall(init)) {
const args = (init as CallExpression).arguments;
if (args.length > 0 && isFunctionLike(args[0])) {
names.push(declarator.id.name);
}
}
}
}
// --- Source measurement ---
function measureComponentInSource(name: string, lines: Array<string>): ComponentInfo | null {
const fnPattern = new RegExp(`^(?:export\\s+)?function\\s+${name}\\s*[(<]`);
const varPattern = new RegExp(`^(?:export\\s+)?const\\s+${name}\\s*[=:]`);
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trimStart();
const isFnDecl = fnPattern.test(trimmed);
const isVarDecl = varPattern.test(trimmed);
if (!isFnDecl && !isVarDecl) continue;
if (isFnDecl) {
const result = measureFromParams(i, lines);
if (result) return { ...result, name };
return null;
}
const result = measureFromArrow(i, lines);
if (result) return { ...result, name };
return null;
}
return null;
}
// function Foo(...) { ... } — skip params via parens, then brace-match the body
function measureFromParams(startLine: number, lines: Array<string>): ComponentInfo | null {
let parenDepth = 0;
let pastParams = false;
let braceDepth = 0;
for (let j = startLine; j < lines.length; j++) {
for (const ch of lines[j]) {
if (!pastParams) {
if (ch === "(") parenDepth++;
else if (ch === ")") {
parenDepth--;
if (parenDepth === 0) pastParams = true;
}
} else {
if (ch === "{") braceDepth++;
else if (ch === "}") {
braceDepth--;
if (braceDepth === 0) {
return { name: "", startLine: startLine + 1, lineCount: j - startLine + 1 };
}
}
}
}
}
return null;
}
// const Foo = (...) => { ... } / const Foo = memo((...) => { ... })
// Find `=>` first, then brace-match from there to skip type annotations in params
function measureFromArrow(startLine: number, lines: Array<string>): ComponentInfo | null {
let arrowFound = false;
let braceDepth = 0;
let foundBrace = false;
for (let j = startLine; j < lines.length; j++) {
const line = lines[j];
for (let c = 0; c < line.length; c++) {
if (!arrowFound) {
if (line[c] === "=" && line[c + 1] === ">") {
arrowFound = true;
c++;
}
continue;
}
if (line[c] === "{") {
braceDepth++;
foundBrace = true;
} else if (line[c] === "}") {
braceDepth--;
if (foundBrace && braceDepth === 0) {
return { name: "", startLine: startLine + 1, lineCount: j - startLine + 1 };
}
}
}
}
return null;
}
// --- Config resolution ---
function createLimitResolver(options: LimitLineOptions): (id: string) => ResolvedLimits {
const matchers = options.overrides.map((override) => ({
match: createFilter(override.files),
maxReactFCLines: override.maxReactFCLines,
maxFileLines: override.maxFileLines,
}));
return (id: string): ResolvedLimits => {
let maxReactFCLines: number | null = options.maxReactFCLines;
let maxFileLines: number | null = options.maxFileLines;
for (const matcher of matchers) {
if (matcher.match(id)) {
maxReactFCLines = matcher.maxReactFCLines;
maxFileLines = matcher.maxFileLines;
}
}
return { maxReactFCLines, maxFileLines };
};
}
function shouldProcess(id: string, options: LimitLineOptions): boolean {
return options.include.test(id) && !id.includes("node_modules") && (options.exclude === null || !options.exclude.test(id));
}
// --- Plugin ---
function viteLimitLinePlugin(
userOptions: Partial<LimitLineOptions> = {},
): Array<Plugin> {
const options: LimitLineOptions = { ...DEFAULT_OPTIONS, ...userOptions, overrides: userOptions.overrides ?? [] };
const resolve = createLimitResolver(options);
const rawCodeCache = new Map<string, string>();
return [
{
name: "vite-plugin-limit-line:pre",
enforce: "pre",
transform(code, id) {
if (!shouldProcess(id, options)) return null;
rawCodeCache.set(id, code);
const limits = resolve(id);
if (limits.maxFileLines === null) return null;
const totalLines = code.split("\n").length;
if (totalLines > limits.maxFileLines) {
this.error(
[
`[vite-limit-line] File too long: ${totalLines} lines (limit: ${limits.maxFileLines})`,
` file: ${id}`,
"",
"How to fix:",
" Split this file into smaller modules — extract related types, helpers,",
" or sub-components into separate files and re-export from an index.ts.",
].join("\n"),
);
}
return null;
},
},
{
name: "vite-plugin-limit-line:fc",
transform(code, id) {
if (!shouldProcess(id, options)) return null;
const limits = resolve(id);
if (limits.maxReactFCLines === null) return null;
const ast = this.parse(code) as unknown as AstProgram;
const componentNames = extractComponentNames(ast);
if (componentNames.length === 0) return null;
const raw = rawCodeCache.get(id) ?? code;
rawCodeCache.delete(id);
const rawLines = raw.split("\n");
const maxFCLines = limits.maxReactFCLines;
const violations: Array<ComponentInfo> = [];
for (const name of componentNames) {
const info = measureComponentInSource(name, rawLines);
if (info && info.lineCount > maxFCLines) {
violations.push(info);
}
}
if (violations.length > 0) {
const details = violations
.map(
(v) =>
` ${v.name} (line ${v.startLine}): ${v.lineCount} lines (limit: ${maxFCLines})`,
)
.join("\n");
this.error(
[
`[vite-limit-line] React component too long in ${id}:`,
details,
"",
"How to fix:",
" Break each oversized component into smaller ones. Extract reusable",
" sections into child components, move complex logic into custom hooks,",
" and keep each component focused on a single responsibility.",
].join("\n"),
);
}
return null;
},
buildEnd() {
rawCodeCache.clear();
},
},
];
}
export { viteLimitLinePlugin };
export type { LimitLineOptions, LimitLineOverride };
+12 -49
View File
@@ -1,75 +1,38 @@
import { useState } from "react";
import { Navigate, Outlet, useParams } from "react-router";
import { clearApiKey, hasApiKey } from "./api.ts";
import { LoginPage } from "./components/login.tsx";
import { RunDialog } from "./components/run-dialog.tsx";
import { Sidebar } from "./components/sidebar.tsx";
import { StatusBar } from "./components/status-bar.tsx";
import { ThreadDetail } from "./components/thread-detail.tsx";
import { ThreadList } from "./components/thread-list.tsx";
import { WorkflowDetail } from "./components/workflow-detail.tsx";
import { WorkflowList } from "./components/workflow-list.tsx";
import { useHashRoute } from "./use-hash-route.ts";
import { useTheme } from "./hooks/use-theme.tsx";
export function App() {
export function Layout() {
const [authed, setAuthed] = useState(hasApiKey());
const { view, client, threadId, workflowName, setView, setClient, setThreadId, setWorkflowName } =
useHashRoute();
const { client } = useParams();
const [showRun, setShowRun] = useState(false);
const { theme, toggleTheme } = useTheme();
if (!authed) {
return <LoginPage onLogin={() => setAuthed(true)} />;
return <Navigate to="/login" replace />;
}
return (
<div className="flex h-screen">
<div className="flex h-screen bg-background">
<Sidebar
view={view}
client={client}
onViewChange={setView}
onClientChange={setClient}
onLogout={() => {
clearApiKey();
setAuthed(false);
}}
theme={theme}
onToggleTheme={toggleTheme}
/>
<main className="flex-1 overflow-hidden flex flex-col">
<StatusBar client={client} onRun={() => setShowRun(true)} />
<StatusBar client={client ?? null} onRun={() => setShowRun(true)} />
<div className="flex-1 overflow-auto p-6">
{!client && (
<div className="flex items-center justify-center h-full">
<p style={{ color: "var(--color-text-muted)" }}>
Select an client from the sidebar to get started.
</p>
</div>
)}
{client && view === "threads" && threadId === null && (
<ThreadList client={client} onSelect={setThreadId} />
)}
{client && view === "threads" && threadId !== null && (
<ThreadDetail client={client} threadId={threadId} onBack={() => setThreadId(null)} />
)}
{client && view === "workflows" && workflowName === null && (
<WorkflowList client={client} onSelect={setWorkflowName} />
)}
{client && view === "workflows" && workflowName !== null && (
<WorkflowDetail
client={client}
workflowName={workflowName}
onBack={() => setWorkflowName(null)}
/>
)}
<Outlet />
</div>
</main>
{showRun && client && (
<RunDialog
client={client}
onClose={() => setShowRun(false)}
onCreated={(id) => {
setShowRun(false);
setThreadId(id);
}}
/>
)}
{client && <RunDialog client={client} open={showRun} onOpenChange={setShowRun} />}
</div>
);
}
@@ -0,0 +1,31 @@
import { Loader2, Users } from "lucide-react";
import { Navigate } from "react-router";
import { listClients } from "../api.ts";
import { useFetch } from "../hooks.ts";
export function ClientRedirect() {
const { status, data } = useFetch(() => listClients(), []);
if (status === "loading") {
return (
<div className="flex flex-col items-center justify-center h-full gap-3">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading clients...</p>
</div>
);
}
if (status === "ok" && data.length > 0) {
return <Navigate to={`/${data[0].name}/threads`} replace />;
}
return (
<div className="flex flex-col items-center justify-center h-full gap-3">
<Users className="h-12 w-12 text-muted-foreground/50" />
<p className="text-sm font-medium">No client selected</p>
<p className="text-xs text-muted-foreground">
Select a client from the sidebar to get started.
</p>
</div>
);
}
@@ -1,14 +1,18 @@
import { AlertCircle, Loader2, Moon, Settings, Sun } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { setApiKey } from "../api.ts";
import { useTheme } from "../hooks/use-theme.tsx";
import { Button } from "./ui/button.tsx";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card.tsx";
import { Input } from "./ui/input.tsx";
type Props = {
onLogin: () => void;
};
export function LoginPage({ onLogin }: Props) {
export function LoginPage() {
const navigate = useNavigate();
const [key, setKey] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const { theme, toggleTheme } = useTheme();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -17,7 +21,6 @@ export function LoginPage({ onLogin }: Props) {
setLoading(true);
setError(null);
// Test the key by hitting the endpoints list
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
try {
const res = await fetch(`${gatewayUrl}/api/gateway/endpoints`, {
@@ -40,56 +43,59 @@ export function LoginPage({ onLogin }: Props) {
}
setApiKey(key.trim());
onLogin();
navigate("/", { replace: true });
}
return (
<div
className="min-h-screen flex items-center justify-center"
style={{ background: "var(--color-bg)" }}
>
<div
className="p-8 rounded-lg border w-full max-w-sm"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
<div className="min-h-screen flex items-center justify-center bg-background relative">
<Button
variant="ghost"
size="icon"
className="absolute top-4 right-4 transition-colors duration-200"
onClick={toggleTheme}
>
<h1 className="text-xl font-bold mb-1" style={{ color: "var(--color-accent)" }}>
Workflow Dashboard
</h1>
<p className="text-sm mb-6" style={{ color: "var(--color-text-muted)" }}>
Enter your API key to continue
</p>
<form onSubmit={handleSubmit}>
<input
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="API Key"
className="w-full px-3 py-2 rounded border text-sm mb-3 outline-none"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
/>
{error && (
<p className="text-xs mb-3" style={{ color: "var(--color-error)" }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading || !key.trim()}
className="w-full px-3 py-2 rounded text-sm font-medium"
style={{
background: "var(--color-accent)",
color: "var(--color-bg)",
opacity: loading || !key.trim() ? 0.5 : 1,
}}
>
{loading ? "Verifying..." : "Login"}
</button>
</form>
</div>
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</Button>
<Card className="w-full max-w-sm shadow-lg transition-all duration-200 hover:shadow-xl hover:border-primary/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl tracking-tight">
<Settings className="h-5 w-5" />
Workflow Dashboard
</CardTitle>
<CardDescription>Enter your API key to continue</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="API Key"
className="transition-all duration-200"
/>
{error && (
<p className="text-xs text-destructive flex items-center gap-1.5">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
{error}
</p>
)}
<Button
type="submit"
disabled={loading || !key.trim()}
className="w-full transition-all duration-200"
>
{loading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Verifying
</span>
) : (
"Login"
)}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

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