Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1b2f0044f | |||
| 33f9425848 | |||
| 2b707fb44e | |||
| 6306b23a9f | |||
| 6bb8cf8315 | |||
| 93b7947d7c | |||
| 9584a86fb7 | |||
| defc0afc27 | |||
| 9f6633d5bf | |||
| 7dadf874e1 | |||
| ba90214af6 | |||
| 5bbac3e4f7 | |||
| 131021b1a7 |
@@ -0,0 +1,363 @@
|
||||
# `wf` — Stateless Workflow CLI
|
||||
|
||||
> 将 workflow 引擎降维为无状态单步 CLI。Workflow 是纯数据(CAS 节点),执行是单步原子操作,agent 是可插拔外部命令。
|
||||
|
||||
---
|
||||
|
||||
## 1. CLI Design
|
||||
|
||||
### 1.1 命令总览
|
||||
|
||||
```
|
||||
wf start <workflow> -p <prompt> # 创建 thread,不执行
|
||||
wf step <thread-id> [--agent <cmd>] # 单步执行
|
||||
wf show <thread-id> # 查看 thread 当前状态
|
||||
wf log <thread-id> # 查看完整 step 历史
|
||||
wf cas <hash> # 读 CAS 节点
|
||||
|
||||
wf workflow put <file.yaml> # 注册 workflow(YAML → CAS)
|
||||
wf workflow show <workflow-id> # 查看 workflow 定义
|
||||
wf workflow list # 列出已注册 workflows
|
||||
```
|
||||
|
||||
7 个命令,没有 run/pause/resume/kill/fork/connect。
|
||||
|
||||
### 1.2 `wf start`
|
||||
|
||||
```bash
|
||||
wf start <workflow> -p "Fix the login bug described in issue #42"
|
||||
```
|
||||
|
||||
- `<workflow>` — workflow 名或 CAS hash
|
||||
- `-p` — 用户 prompt(必填)
|
||||
|
||||
**输出(JSON to stdout):**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"workflow": "0R3KFM8N2P1QW", // workflow CAS hash
|
||||
"thread": "01J7K9M2XN...", // ULID
|
||||
"progress": null // null = 还没执行任何 step
|
||||
}
|
||||
```
|
||||
|
||||
**做的事:**
|
||||
1. 解析 workflow(名字查 registry → CAS hash)
|
||||
2. 生成 thread ULID
|
||||
3. 解析系统 config,确定 agent binding
|
||||
4. 写 StartNode 到 CAS
|
||||
5. 在 threads 索引中记录链头 → StartNode hash
|
||||
6. 输出 JSON
|
||||
|
||||
### 1.3 `wf step`
|
||||
|
||||
```bash
|
||||
wf step 01J7K9M2XN...
|
||||
wf step 01J7K9M2XN... --agent "bunx uwf-cursor"
|
||||
```
|
||||
|
||||
**输出(JSON to stdout):**
|
||||
|
||||
```jsonc
|
||||
// 正常 step
|
||||
{
|
||||
"workflow": "0R3KFM8N2P1QW",
|
||||
"thread": "01J7K9M2XN...",
|
||||
"progress": {
|
||||
"role": "developer",
|
||||
"meta": { "filesChanged": ["src/auth.ts"], "summary": "Fixed redirect" },
|
||||
"detail": "0QWERTY12345A", // agent 原始输出的 CAS hash
|
||||
"agent": "0AGENT_CFG_HASH" // 实际使用的 agent 配置 CAS hash
|
||||
}
|
||||
}
|
||||
|
||||
// thread 结束
|
||||
{
|
||||
"workflow": "0R3KFM8N2P1QW",
|
||||
"thread": "01J7K9M2XN...",
|
||||
"progress": null
|
||||
}
|
||||
```
|
||||
|
||||
**做的事:**
|
||||
1. 读链头 → 当前 StepNode(或 StartNode)
|
||||
2. 收集 thread 历史(遍历链)
|
||||
3. 调 moderator:评估 JSONata conditions → 得到下一个 role(或 END)
|
||||
4. 若 END → 归档 thread,输出 `progress: null`
|
||||
5. 确定 agent command(`--agent` override > thread binding > global default)
|
||||
6. 构建 prompt(role.systemPrompt + thread context + user prompt)
|
||||
7. 调用:`<agent-cmd> -t <thread-id> -p <prompt>`,捕获 stdout
|
||||
8. 解析 agent 输出为 progress JSON
|
||||
9. 写 StepNode 到 CAS(prev → 旧链头)
|
||||
10. 更新链头指针
|
||||
11. 输出 JSON
|
||||
|
||||
### 1.4 `wf show` / `wf log` / `wf cas`
|
||||
|
||||
```bash
|
||||
wf show 01J7K9M2XN... # 当前链头的 StepNode,人类可读
|
||||
wf log 01J7K9M2XN... # 遍历链,打印每一步的 role + meta 摘要
|
||||
wf cas 0QWERTY12345A # 原始 CAS 节点内容(YAML/JSON)
|
||||
```
|
||||
|
||||
这三个是纯读操作,不改状态。
|
||||
|
||||
### 1.5 Agent CLI 协议
|
||||
|
||||
每个 agent 是一个命令前缀:
|
||||
|
||||
```bash
|
||||
uwf-hermes -t <thread-id> -p <prompt>
|
||||
# 或
|
||||
bunx uwf-hermes -t <thread-id> -p <prompt>
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- `-t <thread-id>` — agent 可以用它读 CAS 获取 thread 上下文
|
||||
- `-p <prompt>` — 本次 step 的完整 prompt(wf 已经组装好)
|
||||
- stdout → progress 中 `meta` + raw output
|
||||
- 所有配置从环境变量读(LLM model、API key、extractor config)
|
||||
- exit 0 = 成功,非 0 = 失败
|
||||
|
||||
**stdout 输出格式:**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"meta": { ... }, // 结构化输出,符合 role schema
|
||||
"content": "..." // 原始输出文本(wf 负责存入 CAS)
|
||||
}
|
||||
```
|
||||
|
||||
agent 框架 package(`@uncaged/workflow-agent-kit`)帮 agent 作者完成:
|
||||
- 读 thread CAS 构建上下文
|
||||
- 调实际 agent 逻辑
|
||||
- 调 extractor LLM 从 raw output 提取 meta
|
||||
- 格式化 stdout JSON
|
||||
|
||||
---
|
||||
|
||||
## 2. CAS 结构定义
|
||||
|
||||
### 2.1 类型层级
|
||||
|
||||
沿用 json-cas 的三层:bootstrap meta-schema → JSON Schema nodes → data nodes。
|
||||
|
||||
下面所有 CAS 节点都遵循 `{ type: cas_ref, payload: T, timestamp: number }` 的标准格式。
|
||||
|
||||
### 2.2 Schema 节点
|
||||
|
||||
以下每个 schema 本身是一个 CAS 节点(type 指向 meta-schema)。
|
||||
|
||||
#### `RoleSchema`
|
||||
|
||||
```yaml
|
||||
# 定义一个 role 的 meta 输出格式
|
||||
# 例:developer role 的输出 schema
|
||||
type: <meta-schema-hash>
|
||||
payload:
|
||||
$id: "role-output-developer"
|
||||
type: object
|
||||
properties:
|
||||
filesChanged:
|
||||
type: array
|
||||
items: { type: string }
|
||||
summary:
|
||||
type: string
|
||||
required: [filesChanged, summary]
|
||||
```
|
||||
|
||||
### 2.3 数据节点
|
||||
|
||||
#### `Role`
|
||||
|
||||
```yaml
|
||||
type: <role-schema-hash>
|
||||
payload:
|
||||
name: "developer"
|
||||
description: "Implements code changes"
|
||||
systemPrompt: "You are a developer agent..."
|
||||
extractPrompt: "Extract the following from the agent output..."
|
||||
outputSchema:
|
||||
$ref: "0ROLESCH_HASH1" # cas_ref → RoleSchema 节点
|
||||
```
|
||||
|
||||
#### `Moderator`
|
||||
|
||||
```yaml
|
||||
type: <moderator-schema-hash>
|
||||
payload:
|
||||
graph:
|
||||
- from: "$START"
|
||||
transitions:
|
||||
- to: "planner"
|
||||
condition: null # 无条件(default/fallback)
|
||||
- from: "planner"
|
||||
transitions:
|
||||
- to: "developer"
|
||||
condition: "$not($exists(meta.needsClarification))" # JSONata
|
||||
- to: "$END"
|
||||
condition: null # fallback
|
||||
- from: "developer"
|
||||
transitions:
|
||||
- to: "reviewer"
|
||||
condition: null
|
||||
- from: "reviewer"
|
||||
transitions:
|
||||
- to: "developer"
|
||||
condition: "meta.approved = false" # JSONata
|
||||
- to: "$END"
|
||||
condition: null
|
||||
```
|
||||
|
||||
JSONata 表达式的求值上下文:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"role": "reviewer", // 刚完成的 role
|
||||
"meta": { "approved": false }, // 刚完成的 role 的 meta
|
||||
"depth": 3, // 当前第几步
|
||||
"history": [ // 所有历史 steps 的摘要
|
||||
{ "role": "planner", "meta": { ... } },
|
||||
{ "role": "developer", "meta": { ... } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `Workflow`
|
||||
|
||||
```yaml
|
||||
type: <workflow-schema-hash>
|
||||
payload:
|
||||
name: "solve-issue"
|
||||
description: "End-to-end issue resolution"
|
||||
roles:
|
||||
planner:
|
||||
$ref: "0ROLE_PLANNER_H" # cas_ref → Role
|
||||
developer:
|
||||
$ref: "0ROLE_DEVELP_H" # cas_ref → Role
|
||||
reviewer:
|
||||
$ref: "0ROLE_REVIEW_H" # cas_ref → Role
|
||||
moderator:
|
||||
$ref: "0MODERATOR_HASH" # cas_ref → Moderator
|
||||
defaultAgent: "uwf-hermes" # 默认 agent 命令前缀
|
||||
agentOverrides: # per-role override
|
||||
developer: "uwf-cursor"
|
||||
```
|
||||
|
||||
#### `AgentConfig`
|
||||
|
||||
```yaml
|
||||
type: <agent-config-schema-hash>
|
||||
payload:
|
||||
command: "uwf-hermes"
|
||||
# 不存 env(从运行环境继承)
|
||||
# 不存 thread-id(运行时变量)
|
||||
# → 相同 command 的 steps 自然共享同一个 CAS hash
|
||||
```
|
||||
|
||||
为什么只存 command?因为 env 从运行环境继承,不进 CAS。这样同一个 agent 命令在不同 step 间去重为同一个 hash。如果需要记录实际运行时的 env snapshot 用于审计,可以单独存一个 `AgentExecContext` 节点,但不作为默认行为。
|
||||
|
||||
#### `StartNode`(Thread 起点)
|
||||
|
||||
```yaml
|
||||
type: <start-node-schema-hash>
|
||||
payload:
|
||||
workflow:
|
||||
$ref: "0WORKFLOW_HASH" # cas_ref → Workflow
|
||||
prompt: "Fix the login bug..."
|
||||
agentBinding: # 启动时确定的 agent 分配
|
||||
planner:
|
||||
$ref: "0AGENTCFG_HRM" # cas_ref → AgentConfig
|
||||
developer:
|
||||
$ref: "0AGENTCFG_CUR" # cas_ref → AgentConfig
|
||||
reviewer:
|
||||
$ref: "0AGENTCFG_HRM" # cas_ref → AgentConfig
|
||||
```
|
||||
|
||||
没有 thread-id 在 payload 里 — thread-id 是索引层面的事,不进 CAS 内容。
|
||||
|
||||
#### `StepNode`(Thread 每一步)
|
||||
|
||||
```yaml
|
||||
type: <step-node-schema-hash>
|
||||
payload:
|
||||
role: "developer"
|
||||
meta:
|
||||
filesChanged: ["src/auth.ts"]
|
||||
summary: "Fixed redirect loop"
|
||||
content:
|
||||
$ref: "0RAW_OUTPUT_HASH" # cas_ref → 原始 agent 输出(content node)
|
||||
agent:
|
||||
$ref: "0AGENTCFG_CUR" # cas_ref → 实际使用的 AgentConfig
|
||||
prev:
|
||||
$ref: "0PREV_STEP_HASH" # cas_ref → 前一个 StepNode 或 StartNode
|
||||
```
|
||||
|
||||
### 2.4 链式结构
|
||||
|
||||
```
|
||||
threads.json: { "01J7K9M2XN...": "0STEP3_HASH" }
|
||||
│
|
||||
▼
|
||||
StepNode (step 3)
|
||||
├── role: "reviewer"
|
||||
├── meta: { approved: true }
|
||||
├── content → CAS(raw output)
|
||||
├── agent → CAS(AgentConfig)
|
||||
└── prev ──→ StepNode (step 2)
|
||||
├── role: "developer"
|
||||
├── ...
|
||||
└── prev ──→ StepNode (step 1)
|
||||
├── role: "planner"
|
||||
├── ...
|
||||
└── prev ──→ StartNode
|
||||
├── workflow → CAS(Workflow)
|
||||
├── prompt: "Fix..."
|
||||
└── agentBinding: {...}
|
||||
```
|
||||
|
||||
### 2.5 可变状态
|
||||
|
||||
整个系统唯一的可变文件:
|
||||
|
||||
```jsonc
|
||||
// ~/.uncaged/workflow/threads.json
|
||||
{
|
||||
"01J7K9M2XN...": "0STEP3_HASH", // active thread → 链头
|
||||
"01J8AB3QRM...": "0STEP1_HASH"
|
||||
}
|
||||
```
|
||||
|
||||
Thread 结束时从这个文件移除。可选:追加一行到 `history.jsonl` 做归档。
|
||||
|
||||
---
|
||||
|
||||
## 3. 包结构(精简后)
|
||||
|
||||
```
|
||||
packages/
|
||||
├── workflow-protocol/ # 类型定义(复用,大幅精简)
|
||||
├── workflow-cas/ # CAS 存储(复用)
|
||||
├── workflow-util/ # Base32, ULID, logger(复用)
|
||||
├── workflow-moderator/ # JSONata moderator 引擎(新,从 #297 演化)
|
||||
├── workflow-agent-kit/ # Agent CLI 框架(新,含 extractor)
|
||||
└── cli-workflow/ # wf CLI(重写)
|
||||
```
|
||||
|
||||
6 个包。砍掉 workflow-runtime, workflow-execute, workflow-register, workflow-reactor, workflow-dashboard, workflow-gateway, 4 个 agent adapter, 2 个 template, workflow(根包)。
|
||||
|
||||
**可以删除的包(12 个):**
|
||||
- `workflow-runtime` — createWorkflow / AsyncGenerator 不再需要
|
||||
- `workflow-execute` — 引擎循环移到 CLI 的 `step` 命令
|
||||
- `workflow-register` — ESM bundle 注册不再需要,workflow 是 CAS 节点
|
||||
- `workflow-reactor` — LLM tool-call 循环移到 agent-kit
|
||||
- `workflow-dashboard` — 没有长驻进程,不需要 dashboard
|
||||
- `workflow-gateway` — 同上
|
||||
- `workflow-agent-cursor` — 变成独立的 `uwf-cursor` CLI
|
||||
- `workflow-agent-hermes` — 变成独立的 `uwf-hermes` CLI
|
||||
- `workflow-agent-llm` — 变成独立的 `uwf-llm` CLI
|
||||
- `workflow-agent-react` — 变成独立的 `uwf-react` CLI
|
||||
- `workflow-template-develop` — 变成 YAML 文件
|
||||
- `workflow-template-solve-issue` — 变成 YAML 文件
|
||||
|
||||
Agent adapters 从 monorepo 内的包变成独立的 CLI 项目(可以在同一个 monorepo,也可以分出去)。Workflow templates 从 ESM bundle 变成 YAML 文件。
|
||||
@@ -20,9 +20,6 @@ import { addCliArgs } from "./bundle-fixture.js";
|
||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {}, graph: { edges: [] } };
|
||||
`;
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
`;
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||
}
|
||||
@@ -52,12 +49,12 @@ describe("cli workflow commands", () => {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}import fs from "node:fs";
|
||||
`${fixtureDescriptor}import fs from "node:fs";
|
||||
|
||||
export const run = async function* (input, options) {
|
||||
fs.existsSync(".");
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, input.prompt);
|
||||
const h = await cas.put(input.prompt);
|
||||
yield { role: "noop", contentHash: h, meta: { done: true }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
}
|
||||
@@ -155,10 +152,9 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
|
||||
},
|
||||
graph: { edges: [] },
|
||||
};
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, input.prompt);
|
||||
const h = await cas.put( input.prompt);
|
||||
yield { role: "greeter", contentHash: h, meta: { greeting: "hi" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "ok" };
|
||||
};
|
||||
@@ -197,9 +193,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -228,9 +224,9 @@ export const run = async function* (input, options) {
|
||||
const dtsPath = join(bundleDir, "types.d.ts");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -261,9 +257,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -284,16 +280,16 @@ export const run = async function* (input, options) {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v1");
|
||||
const h = await cas.put( "v1");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v1" };
|
||||
}
|
||||
`;
|
||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v2");
|
||||
const h = await cas.put( "v2");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v2" };
|
||||
}
|
||||
@@ -326,16 +322,16 @@ export const run = async function* (input, options) {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v1");
|
||||
const h = await cas.put( "v1");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v1" };
|
||||
}
|
||||
`;
|
||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v2");
|
||||
const h = await cas.put( "v2");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v2" };
|
||||
}
|
||||
@@ -378,9 +374,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -391,9 +387,9 @@ export const run = async function* (input, options) {
|
||||
expect(add1.ok).toBe(true);
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "y");
|
||||
const h = await cas.put( "y");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "y" };
|
||||
}
|
||||
@@ -446,9 +442,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -463,9 +459,9 @@ export const run = async function* (input, options) {
|
||||
const hash1 = add1.value.hash;
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "y");
|
||||
const h = await cas.put( "y");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "y" };
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@ import { addCliArgs } from "./bundle-fixture.js";
|
||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||
|
||||
/** Three-role workflow that respects `input.steps` for fork/resume. */
|
||||
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
|
||||
export const descriptor = {
|
||||
const threeRoleBundleSource = `export const descriptor = {
|
||||
description: "fork-cli",
|
||||
roles: {
|
||||
planner: { description: "planner", schema: {} },
|
||||
@@ -30,16 +28,16 @@ export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const has = (r) => input.steps.some((s) => s.role === r);
|
||||
if (!has("planner")) {
|
||||
const h = await putContentMerkleNode(cas, "p1");
|
||||
const h = await cas.put( "p1");
|
||||
yield { role: "planner", contentHash: h, meta: { k: "planner" }, refs: [h] };
|
||||
}
|
||||
if (!has("coder")) {
|
||||
const h = await putContentMerkleNode(cas, "c1");
|
||||
const h = await cas.put( "c1");
|
||||
yield { role: "coder", contentHash: h, meta: { k: "coder" }, refs: [h] };
|
||||
}
|
||||
if (!has("reviewer")) {
|
||||
const body = "rev-" + String(input.steps.length);
|
||||
const h = await putContentMerkleNode(cas, body);
|
||||
const h = await cas.put( body);
|
||||
yield { role: "reviewer", contentHash: h, meta: { k: "reviewer" }, refs: [h] };
|
||||
}
|
||||
return { returnCode: 0, summary: "done" };
|
||||
|
||||
@@ -23,9 +23,6 @@ import { resolveThreadRecord } from "../src/thread-scan.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
`;
|
||||
|
||||
const threadFixtureDescriptor = `export const descriptor = {
|
||||
description: "thread-cli",
|
||||
roles: {
|
||||
@@ -41,25 +38,23 @@ const threadFixtureDescriptor = `export const descriptor = {
|
||||
`;
|
||||
|
||||
const fastBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
let h = await cas.put( "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
h = await cas.put( "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const slowPlannerBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
let h = await cas.put( "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
h = await cas.put( "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
@@ -68,37 +63,34 @@ export const run = async function* (input, options) {
|
||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
|
||||
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
let h = await cas.put( "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
await new Promise((r) => setTimeout(r, 10000));
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
h = await cas.put( "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const pauseResumeBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "f");
|
||||
let h = await cas.put( "f");
|
||||
yield { role: "first", contentHash: h, meta: {}, refs: [h] };
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
h = await putContentMerkleNode(cas, "s");
|
||||
h = await cas.put( "s");
|
||||
yield { role: "second", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (_input, options) {
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,29 +1,16 @@
|
||||
export { createCasStore } from "./cas.js";
|
||||
export { collectRefs } from "./collect-refs.js";
|
||||
export { hashString, hashWorkflowBundleBytes } from "./hash.js";
|
||||
export { hashWorkflowBundleBytes } from "./hash.js";
|
||||
export {
|
||||
createContentMerkleNode,
|
||||
getContentMerklePayload,
|
||||
parseMerkleNode,
|
||||
putContentMerkleNode,
|
||||
putStepMerkleNode,
|
||||
putThreadMerkleNode,
|
||||
serializeMerkleNode,
|
||||
} from "./merkle.js";
|
||||
export type { ParsedCasThreadNode } from "./nodes.js";
|
||||
export {
|
||||
isCasNodeYaml,
|
||||
parseCasThreadNode,
|
||||
putContentNodeWithRefs,
|
||||
putStartNode,
|
||||
putStateNode,
|
||||
serializeCasNode,
|
||||
} from "./nodes.js";
|
||||
export { findReachableHashes } from "./reachable.js";
|
||||
export type {
|
||||
CasStore,
|
||||
MerkleNode,
|
||||
MerkleNodeType,
|
||||
StepMerklePayload,
|
||||
ThreadMerklePayload,
|
||||
} from "./types.js";
|
||||
export type { CasStore } from "./types.js";
|
||||
|
||||
@@ -53,6 +53,35 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
|
||||
return states;
|
||||
}
|
||||
|
||||
function isClickableGraphNode(nodeStates: Map<string, NodeState>, nodeId: string): boolean {
|
||||
const state = nodeStates.get(nodeId);
|
||||
return state !== undefined && state !== "default";
|
||||
}
|
||||
|
||||
function scrollToFirstRecord(): void {
|
||||
const firstCard = document.querySelector('[data-record-index="0"]');
|
||||
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
function scrollToRoleOccurrence(
|
||||
nodeId: string,
|
||||
indicesByRole: Map<string, number[]>,
|
||||
clickCycleRef: { current: Map<string, number> },
|
||||
onHighlight: (role: string) => void,
|
||||
): void {
|
||||
const indices = indicesByRole.get(nodeId);
|
||||
if (indices === undefined || indices.length === 0) return;
|
||||
|
||||
const cycle = clickCycleRef.current.get(nodeId) ?? 0;
|
||||
const idx = indices[cycle % indices.length];
|
||||
clickCycleRef.current.set(nodeId, cycle + 1);
|
||||
|
||||
const el = document.querySelector(`[data-record-index="${idx}"]`);
|
||||
if (el === null) return;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
onHighlight(nodeId);
|
||||
}
|
||||
|
||||
export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||
const sse = useSSE(client, threadId);
|
||||
const { status, data, error } = useFetch(() => getThread(client, threadId), [client, threadId]);
|
||||
@@ -96,44 +125,29 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||
// Track which occurrence to jump to next per role (cycling)
|
||||
const clickCycleRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
const highlightRole = useCallback((role: string) => {
|
||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||
setHighlightedRole(role);
|
||||
highlightTimerRef.current = setTimeout(() => {
|
||||
setHighlightedRole(null);
|
||||
highlightTimerRef.current = null;
|
||||
}, 1500);
|
||||
}, []);
|
||||
|
||||
const handleGraphNodeClick = useCallback(
|
||||
(nodeId: string) => {
|
||||
// Only allow clicks on lit (non-default) nodes
|
||||
if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
|
||||
|
||||
// __start__: scroll to the first record (thread-start prompt)
|
||||
if (!isClickableGraphNode(nodeStates, nodeId)) return;
|
||||
if (nodeId === "__start__") {
|
||||
const firstCard = document.querySelector('[data-record-index="0"]');
|
||||
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
scrollToFirstRecord();
|
||||
return;
|
||||
}
|
||||
|
||||
// __end__: scroll to bottom
|
||||
if (nodeId === "__end__") {
|
||||
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Role nodes: cycle through occurrences
|
||||
const indices = indicesByRole.get(nodeId);
|
||||
if (indices === undefined || indices.length === 0) return;
|
||||
|
||||
const cycle = clickCycleRef.current.get(nodeId) ?? 0;
|
||||
const idx = indices[cycle % indices.length];
|
||||
clickCycleRef.current.set(nodeId, cycle + 1);
|
||||
|
||||
const el = document.querySelector(`[data-record-index="${idx}"]`);
|
||||
if (el !== null) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||
setHighlightedRole(nodeId);
|
||||
highlightTimerRef.current = setTimeout(() => {
|
||||
setHighlightedRole(null);
|
||||
highlightTimerRef.current = null;
|
||||
}, 1500);
|
||||
}
|
||||
scrollToRoleOccurrence(nodeId, indicesByRole, clickCycleRef, highlightRole);
|
||||
},
|
||||
[nodeStates, indicesByRole],
|
||||
[nodeStates, indicesByRole, highlightRole],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -39,67 +39,119 @@ function resolveType(prop: Record<string, unknown>): string {
|
||||
return String(prop.type ?? "unknown");
|
||||
}
|
||||
|
||||
function variantLabel(
|
||||
variantProps: Record<string, Record<string, unknown>>,
|
||||
variantIndex: number,
|
||||
): string {
|
||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||
if (pDef.const !== undefined) return `${pName}: ${String(pDef.const)}`;
|
||||
}
|
||||
return `Variant ${variantIndex + 1}`;
|
||||
}
|
||||
|
||||
function childPrefixForDepth(depth: number, parentPrefix: string): string {
|
||||
return depth > 0 ? `${parentPrefix} ` : " ";
|
||||
}
|
||||
|
||||
function flattenOneOfVariants(
|
||||
oneOf: Array<Record<string, unknown>>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
): SchemaRow[] {
|
||||
const rows: SchemaRow[] = [];
|
||||
for (let vi = 0; vi < oneOf.length; vi++) {
|
||||
const variant = oneOf[vi];
|
||||
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const isLast = vi === oneOf.length - 1;
|
||||
const connector = isLast ? "└" : "├";
|
||||
rows.push({
|
||||
key: `${keyPrefix}variant-${vi}`,
|
||||
name: `${parentPrefix}${connector} ${variantLabel(variantProps, vi)}`,
|
||||
type: "",
|
||||
description: "",
|
||||
depth,
|
||||
prefix: parentPrefix,
|
||||
isVariantHeader: true,
|
||||
});
|
||||
const variantChildPrefix = `${parentPrefix}${isLast ? " " : "│ "}`;
|
||||
const variantRequired = new Set<string>(
|
||||
Array.isArray(variant.required) ? (variant.required as string[]) : [],
|
||||
);
|
||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||
if (pDef.const !== undefined) continue;
|
||||
rows.push(
|
||||
...flattenProperty(
|
||||
pName,
|
||||
pDef,
|
||||
depth + 1,
|
||||
variantChildPrefix,
|
||||
`${keyPrefix}v${vi}-`,
|
||||
variantRequired,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function flattenSchemaProperties(
|
||||
schema: Record<string, unknown>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
): SchemaRow[] {
|
||||
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const required = new Set<string>(
|
||||
Array.isArray(schema.required) ? (schema.required as string[]) : [],
|
||||
);
|
||||
const rows: SchemaRow[] = [];
|
||||
for (const [name, prop] of Object.entries(props)) {
|
||||
rows.push(...flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function flattenSchema(
|
||||
schema: Record<string, unknown>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
): SchemaRow[] {
|
||||
const rows: SchemaRow[] = [];
|
||||
|
||||
// Handle oneOf / discriminatedUnion
|
||||
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(oneOf) && oneOf.length > 0) {
|
||||
for (let vi = 0; vi < oneOf.length; vi++) {
|
||||
const variant = oneOf[vi];
|
||||
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
let variantLabel = `Variant ${vi + 1}`;
|
||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||
if (pDef.const !== undefined) {
|
||||
variantLabel = `${pName}: ${String(pDef.const)}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const isLast = vi === oneOf.length - 1;
|
||||
const connector = isLast ? "└" : "├";
|
||||
rows.push({
|
||||
key: `${keyPrefix}variant-${vi}`,
|
||||
name: `${parentPrefix}${connector} ${variantLabel}`,
|
||||
type: "",
|
||||
description: "",
|
||||
depth,
|
||||
prefix: parentPrefix,
|
||||
isVariantHeader: true,
|
||||
});
|
||||
const childPrefix = `${parentPrefix}${isLast ? " " : "│ "}`;
|
||||
const variantRequired = new Set<string>(
|
||||
Array.isArray(variant.required) ? (variant.required as string[]) : [],
|
||||
);
|
||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||
if (pDef.const !== undefined) continue;
|
||||
const subRows = flattenProperty(
|
||||
pName,
|
||||
pDef,
|
||||
depth + 1,
|
||||
childPrefix,
|
||||
`${keyPrefix}v${vi}-`,
|
||||
variantRequired,
|
||||
);
|
||||
rows.push(...subRows);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
return flattenOneOfVariants(oneOf, depth, parentPrefix, keyPrefix);
|
||||
}
|
||||
return flattenSchemaProperties(schema, depth, parentPrefix, keyPrefix);
|
||||
}
|
||||
|
||||
function flattenNestedPropertyRows(
|
||||
name: string,
|
||||
prop: Record<string, unknown>,
|
||||
depth: number,
|
||||
parentPrefix: string,
|
||||
keyPrefix: string,
|
||||
hasOneOf: boolean,
|
||||
): SchemaRow[] {
|
||||
const childPrefix = childPrefixForDepth(depth, parentPrefix);
|
||||
const nestedKeyPrefix = `${keyPrefix}${name}-`;
|
||||
|
||||
if (prop.type === "object" && prop.properties !== undefined) {
|
||||
return flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, nestedKeyPrefix);
|
||||
}
|
||||
|
||||
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const required = new Set<string>(
|
||||
Array.isArray(schema.required) ? (schema.required as string[]) : [],
|
||||
);
|
||||
for (const [name, prop] of Object.entries(props)) {
|
||||
const subRows = flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required);
|
||||
rows.push(...subRows);
|
||||
if (prop.type === "array") {
|
||||
const items = prop.items as Record<string, unknown> | undefined;
|
||||
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
|
||||
return flattenSchema(items, depth + 1, childPrefix, nestedKeyPrefix);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
|
||||
if (hasOneOf) {
|
||||
return flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, nestedKeyPrefix);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function flattenProperty(
|
||||
@@ -110,55 +162,23 @@ function flattenProperty(
|
||||
keyPrefix: string,
|
||||
required: Set<string>,
|
||||
): SchemaRow[] {
|
||||
const rows: SchemaRow[] = [];
|
||||
const hasOneOf = Array.isArray(prop.oneOf) && (prop.oneOf as unknown[]).length > 0;
|
||||
let type = hasOneOf ? "⊕ oneOf" : resolveType(prop);
|
||||
if (!required.has(name)) type += "?";
|
||||
const description = String(prop.description ?? "");
|
||||
const displayName = depth > 0 ? `${parentPrefix}└─ ${name}` : name;
|
||||
|
||||
rows.push({
|
||||
key: `${keyPrefix}${name}`,
|
||||
name: displayName,
|
||||
type,
|
||||
description,
|
||||
depth,
|
||||
prefix: parentPrefix,
|
||||
isVariantHeader: false,
|
||||
});
|
||||
|
||||
if (prop.type === "object" && prop.properties !== undefined) {
|
||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||
rows.push(
|
||||
...flattenSchema(
|
||||
prop as Record<string, unknown>,
|
||||
depth + 1,
|
||||
childPrefix,
|
||||
`${keyPrefix}${name}-`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (prop.type === "array") {
|
||||
const items = prop.items as Record<string, unknown> | undefined;
|
||||
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
|
||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||
rows.push(...flattenSchema(items, depth + 1, childPrefix, `${keyPrefix}${name}-`));
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOneOf) {
|
||||
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
|
||||
rows.push(
|
||||
...flattenSchema(
|
||||
prop as Record<string, unknown>,
|
||||
depth + 1,
|
||||
childPrefix,
|
||||
`${keyPrefix}${name}-`,
|
||||
),
|
||||
);
|
||||
}
|
||||
const rows: SchemaRow[] = [
|
||||
{
|
||||
key: `${keyPrefix}${name}`,
|
||||
name: depth > 0 ? `${parentPrefix}└─ ${name}` : name,
|
||||
type,
|
||||
description: String(prop.description ?? ""),
|
||||
depth,
|
||||
prefix: parentPrefix,
|
||||
isVariantHeader: false,
|
||||
},
|
||||
];
|
||||
|
||||
rows.push(...flattenNestedPropertyRows(name, prop, depth, parentPrefix, keyPrefix, hasOneOf));
|
||||
return rows;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,128 @@ function edgeKey(e: WorkflowGraphEdge): string {
|
||||
return `${e.from}->${e.to}::${e.condition}`;
|
||||
}
|
||||
|
||||
function collectNodeIds(edges: readonly WorkflowGraphEdge[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const e of edges) {
|
||||
ids.add(e.from);
|
||||
ids.add(e.to);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function detectBackEdges(ids: Set<string>, edges: readonly WorkflowGraphEdge[]): Set<string> {
|
||||
const WHITE = 0;
|
||||
const GRAY = 1;
|
||||
const BLACK = 2;
|
||||
const backEdges = new Set<string>();
|
||||
const color = new Map<string, number>();
|
||||
for (const id of ids) color.set(id, WHITE);
|
||||
|
||||
const fullAdj = new Map<string, string[]>();
|
||||
for (const id of ids) fullAdj.set(id, []);
|
||||
for (const e of edges) {
|
||||
if (e.from !== e.to) fullAdj.get(e.from)?.push(e.to);
|
||||
}
|
||||
|
||||
function dfs(u: string): void {
|
||||
color.set(u, GRAY);
|
||||
for (const v of fullAdj.get(u) ?? []) {
|
||||
const c = color.get(v) ?? WHITE;
|
||||
if (c === GRAY) {
|
||||
backEdges.add(`${u}->${v}`);
|
||||
} else if (c === WHITE) {
|
||||
dfs(v);
|
||||
}
|
||||
}
|
||||
color.set(u, BLACK);
|
||||
}
|
||||
|
||||
if (ids.has(START_ID)) dfs(START_ID);
|
||||
for (const id of ids) {
|
||||
if ((color.get(id) ?? WHITE) === WHITE) dfs(id);
|
||||
}
|
||||
return backEdges;
|
||||
}
|
||||
|
||||
function buildDagAdjacency(
|
||||
ids: Set<string>,
|
||||
edges: readonly WorkflowGraphEdge[],
|
||||
backEdges: Set<string>,
|
||||
): Map<string, string[]> {
|
||||
const adj = new Map<string, string[]>();
|
||||
for (const id of ids) adj.set(id, []);
|
||||
for (const e of edges) {
|
||||
if (e.from === e.to) continue;
|
||||
if (backEdges.has(`${e.from}->${e.to}`)) continue;
|
||||
adj.get(e.from)?.push(e.to);
|
||||
}
|
||||
return adj;
|
||||
}
|
||||
|
||||
function computeInDegrees(ids: Set<string>, adj: Map<string, string[]>): Map<string, number> {
|
||||
const inDegree = new Map<string, number>();
|
||||
for (const id of ids) inDegree.set(id, 0);
|
||||
for (const id of ids) {
|
||||
for (const next of adj.get(id) ?? []) {
|
||||
inDegree.set(next, (inDegree.get(next) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return inDegree;
|
||||
}
|
||||
|
||||
function relaxLongestPathNeighbors(
|
||||
cur: string,
|
||||
curRank: number,
|
||||
adj: Map<string, string[]>,
|
||||
rank: Map<string, number>,
|
||||
inDegree: Map<string, number>,
|
||||
queue: string[],
|
||||
): void {
|
||||
for (const next of adj.get(cur) ?? []) {
|
||||
const prevRank = rank.get(next) ?? 0;
|
||||
if (curRank + 1 > prevRank) rank.set(next, curRank + 1);
|
||||
const deg = (inDegree.get(next) ?? 1) - 1;
|
||||
inDegree.set(next, deg);
|
||||
if (deg === 0) queue.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
function longestPathRanks(ids: Set<string>, adj: Map<string, string[]>): Map<string, number> {
|
||||
const inDegree = computeInDegrees(ids, adj);
|
||||
const rank = new Map<string, number>();
|
||||
const queue: string[] = [];
|
||||
for (const id of ids) {
|
||||
if ((inDegree.get(id) ?? 0) === 0) {
|
||||
queue.push(id);
|
||||
rank.set(id, 0);
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.shift();
|
||||
if (cur === undefined) break;
|
||||
relaxLongestPathNeighbors(cur, rank.get(cur) ?? 0, adj, rank, inDegree, queue);
|
||||
}
|
||||
return rank;
|
||||
}
|
||||
|
||||
function compareLayerNodes(a: string, b: string): number {
|
||||
if (a === START_ID) return -1;
|
||||
if (b === START_ID) return 1;
|
||||
if (a === END_ID) return 1;
|
||||
if (b === END_ID) return -1;
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
function ranksToLayers(rank: Map<string, number>): string[][] {
|
||||
const maxRank = Math.max(...[...rank.values()], 0);
|
||||
const layers: string[][] = [];
|
||||
for (let r = 0; r <= maxRank; r++) layers.push([]);
|
||||
for (const [id, r] of rank) layers[r].push(id);
|
||||
for (const layer of layers) layer.sort(compareLayerNodes);
|
||||
return layers.filter((l) => l.length > 0);
|
||||
}
|
||||
|
||||
// ── Strategy 1: Longest-path layering (Sugiyama step 1) ─────────────
|
||||
|
||||
/**
|
||||
@@ -49,123 +171,11 @@ function edgeKey(e: WorkflowGraphEdge): string {
|
||||
* on the resulting DAG, then the removed edges become feedback edges.
|
||||
*/
|
||||
function computeLayersLongestPath(edges: readonly WorkflowGraphEdge[]): string[][] {
|
||||
// Collect all node IDs
|
||||
const ids = new Set<string>();
|
||||
for (const e of edges) {
|
||||
ids.add(e.from);
|
||||
ids.add(e.to);
|
||||
}
|
||||
|
||||
// Build adjacency (excluding self-loops)
|
||||
const adj = new Map<string, string[]>();
|
||||
const inEdges = new Map<string, string[]>();
|
||||
for (const id of ids) {
|
||||
adj.set(id, []);
|
||||
inEdges.set(id, []);
|
||||
}
|
||||
// Detect back-edges via DFS to break cycles
|
||||
const backEdges = new Set<string>();
|
||||
{
|
||||
const WHITE = 0;
|
||||
const GRAY = 1;
|
||||
const BLACK = 2;
|
||||
const color = new Map<string, number>();
|
||||
for (const id of ids) color.set(id, WHITE);
|
||||
|
||||
// Temporary full adjacency for cycle detection
|
||||
const fullAdj = new Map<string, string[]>();
|
||||
for (const id of ids) fullAdj.set(id, []);
|
||||
for (const e of edges) {
|
||||
if (e.from !== e.to) fullAdj.get(e.from)?.push(e.to);
|
||||
}
|
||||
|
||||
function dfs(u: string): void {
|
||||
color.set(u, GRAY);
|
||||
for (const v of fullAdj.get(u) ?? []) {
|
||||
const c = color.get(v) ?? WHITE;
|
||||
if (c === GRAY) {
|
||||
// Back-edge: u -> v where v is an ancestor
|
||||
backEdges.add(`${u}->${v}`);
|
||||
} else if (c === WHITE) {
|
||||
dfs(v);
|
||||
}
|
||||
}
|
||||
color.set(u, BLACK);
|
||||
}
|
||||
|
||||
// Start DFS from __start__ first for determinism
|
||||
if (ids.has(START_ID)) dfs(START_ID);
|
||||
for (const id of ids) {
|
||||
if ((color.get(id) ?? WHITE) === WHITE) dfs(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Build DAG adjacency (without back-edges)
|
||||
for (const e of edges) {
|
||||
if (e.from === e.to) continue;
|
||||
if (backEdges.has(`${e.from}->${e.to}`)) continue;
|
||||
adj.get(e.from)?.push(e.to);
|
||||
inEdges.get(e.to)?.push(e.from);
|
||||
}
|
||||
|
||||
// Longest-path ranking via topological order (Kahn's algorithm)
|
||||
const inDegree = new Map<string, number>();
|
||||
for (const id of ids) inDegree.set(id, 0);
|
||||
for (const id of ids) {
|
||||
for (const next of adj.get(id) ?? []) {
|
||||
inDegree.set(next, (inDegree.get(next) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const rank = new Map<string, number>();
|
||||
const queue: string[] = [];
|
||||
for (const id of ids) {
|
||||
if ((inDegree.get(id) ?? 0) === 0) {
|
||||
queue.push(id);
|
||||
rank.set(id, 0);
|
||||
}
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.shift()!;
|
||||
const curRank = rank.get(cur) ?? 0;
|
||||
for (const next of adj.get(cur) ?? []) {
|
||||
// Longest path: take max
|
||||
const prevRank = rank.get(next) ?? 0;
|
||||
if (curRank + 1 > prevRank) {
|
||||
rank.set(next, curRank + 1);
|
||||
}
|
||||
const deg = (inDegree.get(next) ?? 1) - 1;
|
||||
inDegree.set(next, deg);
|
||||
if (deg === 0) {
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group by rank
|
||||
const maxRank = Math.max(...[...rank.values()], 0);
|
||||
const layers: string[][] = [];
|
||||
for (let r = 0; r <= maxRank; r++) {
|
||||
layers.push([]);
|
||||
}
|
||||
for (const [id, r] of rank) {
|
||||
layers[r].push(id);
|
||||
}
|
||||
|
||||
// Sort within layers alphabetically for stability, but __start__ first, __end__ last
|
||||
for (const layer of layers) {
|
||||
layer.sort((a, b) => {
|
||||
if (a === START_ID) return -1;
|
||||
if (b === START_ID) return 1;
|
||||
if (a === END_ID) return 1;
|
||||
if (b === END_ID) return -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
// Remove empty layers
|
||||
return layers.filter((l) => l.length > 0);
|
||||
const ids = collectNodeIds(edges);
|
||||
const backEdges = detectBackEdges(ids, edges);
|
||||
const adj = buildDagAdjacency(ids, edges, backEdges);
|
||||
const rank = longestPathRanks(ids, adj);
|
||||
return ranksToLayers(rank);
|
||||
}
|
||||
|
||||
// ── Shared helpers ──────────────────────────────────────────────────
|
||||
@@ -201,132 +211,164 @@ function buildTerminalNode(
|
||||
};
|
||||
}
|
||||
|
||||
// ── Longest-path layout (uses same edge-building as before) ─────────
|
||||
type EdgeLayoutContext = {
|
||||
rank: Map<string, number>;
|
||||
nodePositions: Map<string, { x: number; y: number; w: number; h: number }>;
|
||||
centerX: number;
|
||||
routedCountByTarget: Map<string, number>;
|
||||
};
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: layout logic is inherently branchy
|
||||
function computeLayoutLongestPath(input: LayoutInput): LayoutResult {
|
||||
const layers = computeLayersLongestPath(input.edges);
|
||||
function computeEdgeLabelPosition(
|
||||
e: WorkflowGraphEdge,
|
||||
ctx: EdgeLayoutContext,
|
||||
isFeedback: boolean,
|
||||
isSkipForward: boolean,
|
||||
isSelfLoop: boolean,
|
||||
): { labelX: number | null; labelY: number | null; feedbackSide: "right" | "left" | null } {
|
||||
const sourcePos = ctx.nodePositions.get(e.from);
|
||||
const targetPos = ctx.nodePositions.get(e.to);
|
||||
if (sourcePos === undefined || targetPos === undefined) {
|
||||
return { labelX: null, labelY: null, feedbackSide: null };
|
||||
}
|
||||
|
||||
// Flatten layers into a rank map (layer index = rank)
|
||||
if (isFeedback || isSkipForward) {
|
||||
const count = ctx.routedCountByTarget.get(e.to) ?? 0;
|
||||
ctx.routedCountByTarget.set(e.to, count + 1);
|
||||
const feedbackSide = count % 2 === 0 ? "right" : "left";
|
||||
const offsetX =
|
||||
feedbackSide === "right"
|
||||
? ctx.centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X
|
||||
: ctx.centerX - ROLE_NODE_WIDTH / 2 - FEEDBACK_OFFSET_X;
|
||||
const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2;
|
||||
return { labelX: offsetX, labelY: midY, feedbackSide };
|
||||
}
|
||||
|
||||
if (isSelfLoop) {
|
||||
return { labelX: null, labelY: null, feedbackSide: null };
|
||||
}
|
||||
|
||||
const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2;
|
||||
return { labelX: ctx.centerX, labelY: midY, feedbackSide: null };
|
||||
}
|
||||
|
||||
function buildConditionEdge(e: WorkflowGraphEdge, ctx: EdgeLayoutContext): Edge {
|
||||
const isFallback = e.condition === "FALLBACK";
|
||||
const isSelfLoop = e.from === e.to;
|
||||
const sourceRank = ctx.rank.get(e.from) ?? 0;
|
||||
const targetRank = ctx.rank.get(e.to) ?? 0;
|
||||
const isFeedback = !isSelfLoop && targetRank <= sourceRank;
|
||||
const isSkipForward = !isSelfLoop && !isFeedback && targetRank - sourceRank > 1;
|
||||
const routed = isFeedback || isSkipForward;
|
||||
|
||||
const { labelX, labelY, feedbackSide } = computeEdgeLabelPosition(
|
||||
e,
|
||||
ctx,
|
||||
isFeedback,
|
||||
isSkipForward,
|
||||
isSelfLoop,
|
||||
);
|
||||
|
||||
return {
|
||||
id: edgeKey(e),
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
sourceHandle: routed ? (feedbackSide === "left" ? "left-out" : "right-out") : "bottom-out",
|
||||
targetHandle: routed ? (feedbackSide === "left" ? "left-in" : "right-in") : "top-in",
|
||||
type: "condition",
|
||||
data: {
|
||||
condition: e.condition,
|
||||
conditionDescription: e.conditionDescription,
|
||||
isFallback,
|
||||
isFeedback: routed,
|
||||
isSelfLoop,
|
||||
feedbackSide,
|
||||
labelX,
|
||||
labelY,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const LAYER_H_GAP = 40;
|
||||
|
||||
type NodePosition = { x: number; y: number; w: number; h: number };
|
||||
|
||||
function layerIndexRank(layers: string[][]): Map<string, number> {
|
||||
const rank = new Map<string, number>();
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
for (const id of layers[i]) {
|
||||
rank.set(id, i);
|
||||
}
|
||||
for (const id of layers[i]) rank.set(id, i);
|
||||
}
|
||||
return rank;
|
||||
}
|
||||
|
||||
// Horizontal gap between nodes in the same layer
|
||||
const H_GAP = 40;
|
||||
|
||||
// Position nodes: each layer is a horizontal row
|
||||
const nodePositions = new Map<string, { x: number; y: number; w: number; h: number }>();
|
||||
|
||||
// Find max layer width for centering
|
||||
const layerWidths: number[] = [];
|
||||
for (const layer of layers) {
|
||||
function computeLayerWidths(layers: string[][], hGap: number): number[] {
|
||||
return layers.map((layer) => {
|
||||
let w = 0;
|
||||
for (const id of layer) {
|
||||
w += nodeSize(id).width;
|
||||
}
|
||||
w += (layer.length - 1) * H_GAP;
|
||||
layerWidths.push(w);
|
||||
}
|
||||
const maxLayerWidth = Math.max(...layerWidths, ROLE_NODE_WIDTH);
|
||||
const centerX = maxLayerWidth / 2;
|
||||
for (const id of layer) w += nodeSize(id).width;
|
||||
return w + (layer.length - 1) * hGap;
|
||||
});
|
||||
}
|
||||
|
||||
function layoutNodePositions(
|
||||
layers: string[][],
|
||||
layerWidths: number[],
|
||||
centerX: number,
|
||||
hGap: number,
|
||||
): Map<string, NodePosition> {
|
||||
const nodePositions = new Map<string, NodePosition>();
|
||||
let y = 0;
|
||||
for (let li = 0; li < layers.length; li++) {
|
||||
const layer = layers[li];
|
||||
const totalWidth = layerWidths[li];
|
||||
let x = centerX - totalWidth / 2;
|
||||
let x = centerX - layerWidths[li] / 2;
|
||||
let maxH = 0;
|
||||
for (const id of layer) {
|
||||
const size = nodeSize(id);
|
||||
nodePositions.set(id, { x, y, w: size.width, h: size.height });
|
||||
x += size.width + H_GAP;
|
||||
x += size.width + hGap;
|
||||
if (size.height > maxH) maxH = size.height;
|
||||
}
|
||||
y += maxH + LAYER_GAP;
|
||||
}
|
||||
return nodePositions;
|
||||
}
|
||||
|
||||
// Build nodes
|
||||
function buildLayoutNodes(
|
||||
layers: string[][],
|
||||
nodePositions: Map<string, NodePosition>,
|
||||
input: LayoutInput,
|
||||
): Node[] {
|
||||
const nodes: Node[] = [];
|
||||
for (const layer of layers) {
|
||||
for (const id of layer) {
|
||||
const pos = nodePositions.get(id);
|
||||
if (pos === undefined) continue;
|
||||
const state = input.nodeStates.get(id) ?? "default";
|
||||
const xy = { x: pos.x, y: pos.y };
|
||||
if (id === START_ID || id === END_ID) {
|
||||
nodes.push(buildTerminalNode(id, { x: pos.x, y: pos.y }, state));
|
||||
nodes.push(buildTerminalNode(id, xy, state));
|
||||
} else {
|
||||
nodes.push(buildRoleNode(id, { x: pos.x, y: pos.y }, input.roles, state));
|
||||
nodes.push(buildRoleNode(id, xy, input.roles, state));
|
||||
}
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// Build edges with label positions
|
||||
const routedCountByTarget = new Map<string, number>();
|
||||
const edges: Edge[] = input.edges.map((e) => {
|
||||
const isFallback = e.condition === "FALLBACK";
|
||||
const isSelfLoop = e.from === e.to;
|
||||
const sourceRank = rank.get(e.from) ?? 0;
|
||||
const targetRank = rank.get(e.to) ?? 0;
|
||||
const isFeedback = !isSelfLoop && targetRank <= sourceRank;
|
||||
const isSkipForward = !isSelfLoop && !isFeedback && targetRank - sourceRank > 1;
|
||||
|
||||
const sourcePos = nodePositions.get(e.from);
|
||||
const targetPos = nodePositions.get(e.to);
|
||||
|
||||
let labelX: number | null = null;
|
||||
let labelY: number | null = null;
|
||||
let feedbackSide: "right" | "left" | null = null;
|
||||
|
||||
if (sourcePos !== undefined && targetPos !== undefined) {
|
||||
if (isFeedback || isSkipForward) {
|
||||
const count = routedCountByTarget.get(e.to) ?? 0;
|
||||
routedCountByTarget.set(e.to, count + 1);
|
||||
feedbackSide = count % 2 === 0 ? "right" : "left";
|
||||
const offsetX =
|
||||
feedbackSide === "right"
|
||||
? centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X
|
||||
: centerX - ROLE_NODE_WIDTH / 2 - FEEDBACK_OFFSET_X;
|
||||
const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2;
|
||||
labelX = offsetX;
|
||||
labelY = midY;
|
||||
} else if (!isSelfLoop) {
|
||||
const midX = centerX;
|
||||
const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2;
|
||||
labelX = midX;
|
||||
labelY = midY;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: edgeKey(e),
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
sourceHandle:
|
||||
isFeedback || isSkipForward
|
||||
? feedbackSide === "left"
|
||||
? "left-out"
|
||||
: "right-out"
|
||||
: "bottom-out",
|
||||
targetHandle:
|
||||
isFeedback || isSkipForward ? (feedbackSide === "left" ? "left-in" : "right-in") : "top-in",
|
||||
type: "condition",
|
||||
data: {
|
||||
condition: e.condition,
|
||||
conditionDescription: e.conditionDescription,
|
||||
isFallback,
|
||||
isFeedback: isFeedback || isSkipForward,
|
||||
isSelfLoop,
|
||||
feedbackSide,
|
||||
labelX,
|
||||
labelY,
|
||||
},
|
||||
};
|
||||
});
|
||||
// ── Longest-path layout (uses same edge-building as before) ─────────
|
||||
|
||||
function computeLayoutLongestPath(input: LayoutInput): LayoutResult {
|
||||
const layers = computeLayersLongestPath(input.edges);
|
||||
const rank = layerIndexRank(layers);
|
||||
const layerWidths = computeLayerWidths(layers, LAYER_H_GAP);
|
||||
const centerX = Math.max(...layerWidths, ROLE_NODE_WIDTH) / 2;
|
||||
const nodePositions = layoutNodePositions(layers, layerWidths, centerX, LAYER_H_GAP);
|
||||
const nodes = buildLayoutNodes(layers, nodePositions, input);
|
||||
const edgeCtx: EdgeLayoutContext = {
|
||||
rank,
|
||||
nodePositions,
|
||||
centerX,
|
||||
routedCountByTarget: new Map<string, number>(),
|
||||
};
|
||||
const edges: Edge[] = input.edges.map((e) => buildConditionEdge(e, edgeCtx));
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,7 @@ import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { createServer, type Socket } from "node:net";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import {
|
||||
importWorkflowBundleModule,
|
||||
} from "@uncaged/workflow-register";
|
||||
import { importWorkflowBundleModule } from "@uncaged/workflow-register";
|
||||
import type { RoleOutput, WorkflowFn } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
createLogger,
|
||||
|
||||
@@ -1,48 +1,22 @@
|
||||
export { createWorkflow } from "./engine/create-workflow.js";
|
||||
export { executeThread } from "./engine/engine.js";
|
||||
export {
|
||||
FORK_BRANCH_ROLE,
|
||||
prepareCasFork,
|
||||
tryParseWorkflowResultRecord,
|
||||
walkStateFramesNewestFirst,
|
||||
} from "./engine/fork-thread.js";
|
||||
export { garbageCollectCas } from "./engine/gc.js";
|
||||
export { createThreadPauseGate } from "./engine/thread-pause-gate.js";
|
||||
export type {
|
||||
ThreadHistoryEntry,
|
||||
ThreadIndex,
|
||||
ThreadIndexEntry,
|
||||
} from "./engine/threads-index.js";
|
||||
export {
|
||||
appendThreadHistoryEntry,
|
||||
getBundleDir,
|
||||
readThreadsIndex,
|
||||
removeThreadEntry,
|
||||
removeThreadHistoryEntries,
|
||||
upsertThreadEntry,
|
||||
writeThreadsIndex,
|
||||
} from "./engine/threads-index.js";
|
||||
export type {
|
||||
CasForkPlan,
|
||||
ChainState,
|
||||
ExecuteThreadIo,
|
||||
ExecuteThreadOptions,
|
||||
ForkContinuationOptions,
|
||||
GcResult,
|
||||
PrefilledDiskStep,
|
||||
SupervisorDecision,
|
||||
ThreadPauseGate,
|
||||
} from "./engine/types.js";
|
||||
export { EMPTY_CHAIN_STATE } from "./engine/types.js";
|
||||
export type { GcResult } from "./engine/types.js";
|
||||
export { getWorkerHostScriptPath } from "./engine/worker-entry-path.js";
|
||||
export type { ExtractFn, LlmError, LlmExtractArgs } from "./extract/index.js";
|
||||
export {
|
||||
createExtract,
|
||||
extractFunctionToolFromZodSchema,
|
||||
llmErrorToCause,
|
||||
llmExtract,
|
||||
} from "./extract/index.js";
|
||||
export { createExtract } from "./extract/index.js";
|
||||
export { type WorkflowAdapterOptions, workflowAdapter } from "./workflow-adapter.js";
|
||||
|
||||
/** @deprecated Use {@link workflowAdapter} instead. */
|
||||
export { type WorkflowAsAgentOptions, workflowAsAgent } from "./workflow-as-agent.js";
|
||||
|
||||
@@ -154,12 +154,9 @@ export type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
|
||||
|
||||
/**
|
||||
* Core agent function. Input is always {@link ThreadContext}, output is always string.
|
||||
* `Opt` captures agent-specific structured options.
|
||||
* Agents with no extra options use `AgentFn` (Opt defaults to void).
|
||||
* `Opt` captures agent-specific structured options (required second argument).
|
||||
*/
|
||||
export type AgentFn<Opt = void> = Opt extends void
|
||||
? (ctx: ThreadContext) => Promise<string>
|
||||
: (ctx: ThreadContext, options: Opt) => Promise<string>;
|
||||
export type AgentFn<Opt> = (ctx: ThreadContext, options: Opt) => Promise<string>;
|
||||
|
||||
export type AdapterBinding = {
|
||||
adapter: AdapterFn;
|
||||
@@ -182,7 +179,6 @@ export type RoleDefinition<Meta extends Record<string, unknown>> = {
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
schema: z.ZodType<Meta>;
|
||||
extractRefs: ((meta: Meta) => string[]) | null;
|
||||
};
|
||||
|
||||
export type Moderator<M extends RoleMeta> = (
|
||||
|
||||
@@ -3,10 +3,6 @@ export { createThreadReactor } from "./thread-reactor.js";
|
||||
export type {
|
||||
ChatMessage,
|
||||
LlmFn,
|
||||
StructuredToolSpec,
|
||||
ThreadReactorConfig,
|
||||
ThreadReactorFn,
|
||||
ThreadReactorInvokeArgs,
|
||||
ToolCall,
|
||||
ToolDefinition,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
END,
|
||||
START,
|
||||
type ModeratorTable,
|
||||
type WorkflowDefinition,
|
||||
} from "@uncaged/workflow-protocol";
|
||||
import * as z from "zod/v4";
|
||||
import { buildDescriptor } from "../src/bundle/build-descriptor.js";
|
||||
|
||||
const phaseSchema = z.object({
|
||||
hash: z.string().meta({ "x-cas-ref": true }),
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
type TestMeta = {
|
||||
planner: { phases: Array<{ hash: string; title: string }>; label: string };
|
||||
};
|
||||
|
||||
const testTable: ModeratorTable<TestMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "planner" }],
|
||||
planner: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
|
||||
describe("buildDescriptor", () => {
|
||||
test("preserves x-cas-ref in role JSON Schema", () => {
|
||||
const def: WorkflowDefinition<TestMeta> = {
|
||||
description: "test workflow",
|
||||
roles: {
|
||||
planner: {
|
||||
description: "plans work",
|
||||
systemPrompt: "plan",
|
||||
schema: z.object({
|
||||
phases: z.array(phaseSchema),
|
||||
label: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
table: testTable,
|
||||
};
|
||||
|
||||
const descriptor = buildDescriptor(def);
|
||||
const props = (descriptor.roles.planner.schema as { properties: Record<string, unknown> })
|
||||
.properties;
|
||||
const phaseProps = (
|
||||
(props.phases as { items: { properties: Record<string, unknown> } }).items
|
||||
).properties;
|
||||
|
||||
expect((phaseProps.hash as Record<string, unknown>)["x-cas-ref"]).toBe(true);
|
||||
expect((phaseProps.title as Record<string, unknown>)["x-cas-ref"]).toBeUndefined();
|
||||
expect((props.label as Record<string, unknown>)["x-cas-ref"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,9 @@
|
||||
"zod": "^4.0.0",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
|
||||
@@ -6,10 +6,5 @@ export { stringifyWorkflowDescriptor } from "./generate-descriptor.js";
|
||||
export type {
|
||||
ExtractedBundleExports,
|
||||
WorkflowBundleValidationInput,
|
||||
WorkflowDescriptor,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
} from "./types.js";
|
||||
export { validateWorkflowDescriptor } from "./workflow-descriptor.js";
|
||||
|
||||
@@ -60,6 +60,30 @@ function validateDescriptorGraph(graphRaw: unknown): Result<WorkflowGraph, strin
|
||||
return ok({ edges });
|
||||
}
|
||||
|
||||
function validateDescriptorRole(
|
||||
roleName: string,
|
||||
specUnknown: unknown,
|
||||
): Result<WorkflowRoleDescriptor, string> {
|
||||
if (specUnknown === null || typeof specUnknown !== "object" || Array.isArray(specUnknown)) {
|
||||
return err(`descriptor.roles.${roleName} must be a non-array object`);
|
||||
}
|
||||
const spec = specUnknown as Record<string, unknown>;
|
||||
const roleDesc = spec.description;
|
||||
if (typeof roleDesc !== "string") {
|
||||
return err(`descriptor.roles.${roleName}.description must be a string`);
|
||||
}
|
||||
const schema = spec.schema;
|
||||
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
|
||||
return err(`descriptor.roles.${roleName}.schema must be a non-array object`);
|
||||
}
|
||||
const systemPrompt = typeof spec.systemPrompt === "string" ? spec.systemPrompt : "";
|
||||
return ok({
|
||||
description: roleDesc,
|
||||
systemPrompt,
|
||||
schema: schema as WorkflowRoleSchema,
|
||||
});
|
||||
}
|
||||
|
||||
export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescriptor, string> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return err("descriptor must be a non-array object");
|
||||
@@ -76,24 +100,11 @@ export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescr
|
||||
|
||||
const roles: Record<string, WorkflowRoleDescriptor> = {};
|
||||
for (const [roleName, specUnknown] of Object.entries(rolesRaw)) {
|
||||
if (specUnknown === null || typeof specUnknown !== "object" || Array.isArray(specUnknown)) {
|
||||
return err(`descriptor.roles.${roleName} must be a non-array object`);
|
||||
const roleResult = validateDescriptorRole(roleName, specUnknown);
|
||||
if (!roleResult.ok) {
|
||||
return roleResult;
|
||||
}
|
||||
const spec = specUnknown as Record<string, unknown>;
|
||||
const roleDesc = spec.description;
|
||||
if (typeof roleDesc !== "string") {
|
||||
return err(`descriptor.roles.${roleName}.description must be a string`);
|
||||
}
|
||||
const schema = spec.schema;
|
||||
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
|
||||
return err(`descriptor.roles.${roleName}.schema must be a non-array object`);
|
||||
}
|
||||
const systemPrompt = typeof spec.systemPrompt === "string" ? spec.systemPrompt : "";
|
||||
roles[roleName] = {
|
||||
description: roleDesc,
|
||||
systemPrompt,
|
||||
schema: schema as WorkflowRoleSchema,
|
||||
};
|
||||
roles[roleName] = roleResult.value;
|
||||
}
|
||||
|
||||
const graphResult = validateDescriptorGraph(root.graph);
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
export type {
|
||||
ExtractedBundleExports,
|
||||
WorkflowBundleValidationInput,
|
||||
WorkflowDescriptor,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
} from "./bundle/index.js";
|
||||
export {
|
||||
buildDescriptor,
|
||||
@@ -19,18 +14,15 @@ export type { ProviderConfig, ResolvedModel } from "./config/index.js";
|
||||
export { resolveModel, splitProviderModelRef } from "./config/index.js";
|
||||
export type {
|
||||
WorkflowConfig,
|
||||
WorkflowHistoryEntry,
|
||||
WorkflowRegistryEntry,
|
||||
WorkflowRegistryFile,
|
||||
} from "./registry/index.js";
|
||||
export {
|
||||
getRegisteredWorkflow,
|
||||
listRegisteredWorkflowNames,
|
||||
parseWorkflowRegistryYaml,
|
||||
readWorkflowRegistry,
|
||||
registerWorkflowVersion,
|
||||
rollbackWorkflowToHistoryHash,
|
||||
stringifyWorkflowRegistryYaml,
|
||||
unregisterWorkflow,
|
||||
workflowRegistryPath,
|
||||
writeWorkflowRegistry,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as z from "zod/v4";
|
||||
import { collectCasRefs } from "../src/collect-cas-refs.js";
|
||||
|
||||
const phaseSchema = z.object({
|
||||
hash: z.string().meta({ 'x-cas-ref': true }),
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
const plannerMetaSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("planned"),
|
||||
phases: z.array(phaseSchema),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("aborted"),
|
||||
reason: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
describe("collectCasRefs", () => {
|
||||
test("1. flat field with x-cas-ref annotation", () => {
|
||||
const schema = z.object({
|
||||
completedPhase: z.string().meta({ 'x-cas-ref': true }),
|
||||
});
|
||||
expect(collectCasRefs(schema, { completedPhase: "BHAAAAAAAAAAA" })).toEqual(["BHAAAAAAAAAAA"]);
|
||||
});
|
||||
|
||||
test("2. plain string without annotation is ignored", () => {
|
||||
const schema = z.object({
|
||||
summary: z.string(),
|
||||
completedPhase: z.string().meta({ 'x-cas-ref': true }),
|
||||
});
|
||||
expect(
|
||||
collectCasRefs(schema, {
|
||||
summary: "done",
|
||||
completedPhase: "BHBBBBBBBBBBB",
|
||||
}),
|
||||
).toEqual(["BHBBBBBBBBBBB"]);
|
||||
});
|
||||
|
||||
test("3. nested array of objects collects each annotated hash", () => {
|
||||
const schema = z.object({
|
||||
phases: z.array(phaseSchema),
|
||||
});
|
||||
expect(
|
||||
collectCasRefs(schema, {
|
||||
phases: [
|
||||
{ hash: "BH11111111111", title: "setup" },
|
||||
{ hash: "BH22222222222", title: "impl" },
|
||||
],
|
||||
}),
|
||||
).toEqual(["BH11111111111", "BH22222222222"]);
|
||||
});
|
||||
|
||||
test("4. discriminatedUnion — planner planned branch", () => {
|
||||
expect(
|
||||
collectCasRefs(plannerMetaSchema, {
|
||||
status: "planned",
|
||||
phases: [
|
||||
{ hash: "BH33333333333", title: "a" },
|
||||
{ hash: "BH44444444444", title: "b" },
|
||||
],
|
||||
}),
|
||||
).toEqual(["BH33333333333", "BH44444444444"]);
|
||||
});
|
||||
|
||||
test("4b. discriminatedUnion — planner aborted branch", () => {
|
||||
expect(
|
||||
collectCasRefs(plannerMetaSchema, {
|
||||
status: "aborted",
|
||||
reason: "missing workspace",
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("5. null and undefined annotated fields are skipped", () => {
|
||||
const schema = z.object({
|
||||
ref: z.string().meta({ 'x-cas-ref': true }).nullable(),
|
||||
optionalRef: z.string().meta({ 'x-cas-ref': true }).optional(),
|
||||
});
|
||||
expect(collectCasRefs(schema, { ref: null, optionalRef: undefined })).toEqual([]);
|
||||
expect(collectCasRefs(schema, { ref: "BH55555555555", optionalRef: undefined })).toEqual([
|
||||
"BH55555555555",
|
||||
]);
|
||||
});
|
||||
|
||||
test("6. mixed annotated and plain fields at multiple levels", () => {
|
||||
const schema = z.object({
|
||||
label: z.string(),
|
||||
phase: z.object({
|
||||
hash: z.string().meta({ 'x-cas-ref': true }),
|
||||
title: z.string(),
|
||||
}),
|
||||
tags: z.array(z.string()),
|
||||
});
|
||||
expect(
|
||||
collectCasRefs(schema, {
|
||||
label: "coder",
|
||||
phase: { hash: "BH66666666666", title: "fix" },
|
||||
tags: ["a", "b"],
|
||||
}),
|
||||
).toEqual(["BH66666666666"]);
|
||||
});
|
||||
|
||||
test("7. empty phases array yields no refs", () => {
|
||||
expect(
|
||||
collectCasRefs(plannerMetaSchema, {
|
||||
status: "planned",
|
||||
phases: [],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,6 @@ const roles: WorkflowDefinition<GreetMeta>["roles"] = {
|
||||
systemPrompt:
|
||||
"You are a friendly greeter. Given a user prompt, produce a warm greeting. Respond in valid JSON with keys: greeting (string), language (string).",
|
||||
schema: greeterSchema,
|
||||
extractRefs: null,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import * as z from "zod/v4";
|
||||
|
||||
type ZodSchema = z.ZodType;
|
||||
|
||||
type DefPipeIn = { in: ZodSchema };
|
||||
|
||||
function hasCasRef(schema: ZodSchema): boolean {
|
||||
const meta = z.globalRegistry.get(schema);
|
||||
return meta !== undefined && meta["x-cas-ref"] === true;
|
||||
}
|
||||
|
||||
function walkOptional(schema: z.ZodOptional<ZodSchema>, data: unknown): string[] {
|
||||
if (data === undefined) {
|
||||
return [];
|
||||
}
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkNullable(schema: z.ZodNullable<ZodSchema>, data: unknown): string[] {
|
||||
if (data === null) {
|
||||
return [];
|
||||
}
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkDefault(schema: z.ZodDefault<ZodSchema>, data: unknown): string[] {
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkPrefault(schema: z.ZodPrefault<ZodSchema>, data: unknown): string[] {
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkCatch(schema: z.ZodCatch<ZodSchema>, data: unknown): string[] {
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkReadonly(schema: z.ZodReadonly<ZodSchema>, data: unknown): string[] {
|
||||
return walkCasRefs(schema.unwrap(), data);
|
||||
}
|
||||
|
||||
function walkPipe(def: DefPipeIn, data: unknown): string[] {
|
||||
return walkCasRefs(def.in, data);
|
||||
}
|
||||
|
||||
function walkString(schema: ZodSchema, data: unknown): string[] {
|
||||
if (hasCasRef(schema) && typeof data === "string") {
|
||||
return [data];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function walkObject(schema: z.ZodObject<z.ZodRawShape>, data: unknown): string[] {
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
||||
return [];
|
||||
}
|
||||
const record = data as Record<string, unknown>;
|
||||
const shape = schema.shape;
|
||||
const refs: string[] = [];
|
||||
for (const [key, fieldSchema] of Object.entries(shape)) {
|
||||
refs.push(...walkCasRefs(fieldSchema as ZodSchema, record[key]));
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function walkArray(schema: z.ZodArray<ZodSchema>, data: unknown): string[] {
|
||||
if (!Array.isArray(data)) {
|
||||
return [];
|
||||
}
|
||||
const element = schema.element;
|
||||
const refs: string[] = [];
|
||||
for (const item of data) {
|
||||
refs.push(...walkCasRefs(element, item));
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function walkUnion(schema: z.ZodUnion<readonly ZodSchema[]>, data: unknown): string[] {
|
||||
for (const option of schema.options) {
|
||||
const parsed = option.safeParse(data);
|
||||
if (parsed.success) {
|
||||
return walkCasRefs(option, data);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function walkCasRefs(schema: ZodSchema, data: unknown): string[] {
|
||||
const def = schema.def;
|
||||
|
||||
switch (def.type) {
|
||||
case "optional":
|
||||
return walkOptional(schema as z.ZodOptional<ZodSchema>, data);
|
||||
case "nullable":
|
||||
return walkNullable(schema as z.ZodNullable<ZodSchema>, data);
|
||||
case "default":
|
||||
return walkDefault(schema as z.ZodDefault<ZodSchema>, data);
|
||||
case "prefault":
|
||||
return walkPrefault(schema as z.ZodPrefault<ZodSchema>, data);
|
||||
case "catch":
|
||||
return walkCatch(schema as z.ZodCatch<ZodSchema>, data);
|
||||
case "readonly":
|
||||
return walkReadonly(schema as z.ZodReadonly<ZodSchema>, data);
|
||||
case "pipe":
|
||||
return walkPipe(def as unknown as DefPipeIn, data);
|
||||
case "string":
|
||||
return walkString(schema, data);
|
||||
case "object":
|
||||
return walkObject(schema as z.ZodObject<z.ZodRawShape>, data);
|
||||
case "array":
|
||||
return walkArray(schema as z.ZodArray<ZodSchema>, data);
|
||||
case "union":
|
||||
return walkUnion(schema as z.ZodUnion<readonly ZodSchema[]>, data);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect CAS content hashes from meta using `x-cas-ref` annotations on the Zod schema. */
|
||||
export function collectCasRefs(schema: ZodSchema, data: unknown): string[] {
|
||||
return walkCasRefs(schema, data);
|
||||
}
|
||||
@@ -2,13 +2,13 @@ import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import { collectCasRefs } from "./collect-cas-refs.js";
|
||||
import {
|
||||
type AdapterBinding,
|
||||
type AdapterFn,
|
||||
type AdvanceOutcome,
|
||||
END,
|
||||
type ModeratorContext,
|
||||
type RoleDefinition,
|
||||
type RoleMeta,
|
||||
type RoleOutput,
|
||||
type RoleStep,
|
||||
@@ -26,17 +26,6 @@ function isRoleNext<M extends RoleMeta>(
|
||||
return next !== END;
|
||||
}
|
||||
|
||||
function resolveExtractedRefs(
|
||||
roleDef: RoleDefinition<Record<string, unknown>>,
|
||||
meta: unknown,
|
||||
): string[] {
|
||||
const extractRefsFn = roleDef.extractRefs;
|
||||
if (extractRefsFn === null || typeof extractRefsFn !== "function") {
|
||||
return [];
|
||||
}
|
||||
return extractRefsFn(meta as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function _mergeUniqueHashes(a: readonly string[], b: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
@@ -90,10 +79,7 @@ async function advanceOneRound<M extends RoleMeta>(
|
||||
const result = await roleFn(modCtx as unknown as ThreadContext, runtime);
|
||||
const meta = result.meta;
|
||||
|
||||
const refsFromMeta = resolveExtractedRefs(
|
||||
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
|
||||
meta,
|
||||
);
|
||||
const refsFromMeta = collectCasRefs(roleDef.schema as z.ZodType, meta);
|
||||
|
||||
const contentPayload = JSON.stringify(meta);
|
||||
const contentHash = await putContentNodeWithRefs(runtime.cas, contentPayload, refsFromMeta);
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
export { buildThreadContext } from "./build-context.js";
|
||||
export { createWorkflow } from "./create-workflow.js";
|
||||
export { err, ok } from "./result.js";
|
||||
export type {
|
||||
AdapterBinding,
|
||||
AdapterFn,
|
||||
AgentContext,
|
||||
AgentFn,
|
||||
@@ -14,7 +10,6 @@ export type {
|
||||
ModeratorCondition,
|
||||
ModeratorContext,
|
||||
ModeratorTable,
|
||||
ModeratorTransition,
|
||||
Result,
|
||||
RoleDefinition,
|
||||
RoleFn,
|
||||
@@ -22,17 +17,15 @@ export type {
|
||||
RoleOutput,
|
||||
RoleResult,
|
||||
RoleStep,
|
||||
StartStep,
|
||||
ThreadContext,
|
||||
WorkflowCompletion,
|
||||
WorkflowDefinition,
|
||||
WorkflowDescriptor,
|
||||
WorkflowFn,
|
||||
WorkflowGraph,
|
||||
WorkflowGraphEdge,
|
||||
WorkflowResult,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
WorkflowRuntime,
|
||||
} from "./types.js";
|
||||
export { END, START } from "./types.js";
|
||||
} from "@uncaged/workflow-protocol";
|
||||
export { END, START } from "@uncaged/workflow-protocol";
|
||||
export { buildThreadContext } from "./build-context.js";
|
||||
export { collectCasRefs } from "./collect-cas-refs.js";
|
||||
export { createWorkflow } from "./create-workflow.js";
|
||||
export { err, ok } from "./result.js";
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as z from "zod/v4";
|
||||
export const coderMetaSchema = z.object({
|
||||
completedPhase: z
|
||||
.string()
|
||||
.meta({ casRef: true })
|
||||
.describe(
|
||||
"The planner phase hash finished this round. If multiple phases were completed, use the last finished phase hash.",
|
||||
),
|
||||
@@ -36,5 +37,4 @@ export const coderRole: RoleDefinition<CoderMeta> = {
|
||||
"Implements the next incomplete planner phase and reports structured completion metadata.",
|
||||
systemPrompt: CODER_SYSTEM,
|
||||
schema: coderMetaSchema,
|
||||
extractRefs: (meta) => [meta.completedPhase],
|
||||
};
|
||||
|
||||
@@ -29,5 +29,4 @@ export const committerRole: RoleDefinition<CommitterMeta> = {
|
||||
description: "Creates a branch and commits changes.",
|
||||
systemPrompt: COMMITTER_SYSTEM,
|
||||
schema: committerMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const phaseSchema = z.object({
|
||||
hash: z.string(),
|
||||
hash: z.string().meta({ casRef: true }),
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
@@ -63,5 +63,4 @@ export const plannerRole: RoleDefinition<PlannerMeta> = {
|
||||
description: "Breaks the task into sequential phases for the coder.",
|
||||
systemPrompt: PLANNER_SYSTEM,
|
||||
schema: plannerMetaSchema,
|
||||
extractRefs: (meta) => (meta.status === "planned" ? meta.phases.map((p) => p.hash) : []),
|
||||
};
|
||||
|
||||
@@ -42,5 +42,4 @@ export const reviewerRole: RoleDefinition<ReviewerMeta> = {
|
||||
description: "Runs git diff checks and sets approved when the change is ready.",
|
||||
systemPrompt: REVIEWER_SYSTEM,
|
||||
schema: reviewerMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
|
||||
@@ -24,5 +24,4 @@ export const testerRole: RoleDefinition<TesterMeta> = {
|
||||
description: "Runs test, build, and lint commands and reports pass or fail with details.",
|
||||
systemPrompt: TESTER_SYSTEM,
|
||||
schema: testerMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
|
||||
@@ -31,8 +31,4 @@ describe("submitterRole", () => {
|
||||
expect(submitterRole.systemPrompt).toContain("submitter");
|
||||
expect(submitterRole.systemPrompt).toContain("pull request");
|
||||
});
|
||||
|
||||
test("has no refs extractor", () => {
|
||||
expect(submitterRole.extractRefs).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,5 +21,4 @@ export const developerRole: RoleDefinition<DeveloperMeta> = {
|
||||
"Delegates the actual implementation to the develop workflow (workflow-as-agent). Produces a summary by traversing the child thread's Merkle DAG.",
|
||||
systemPrompt: DEVELOPER_SYSTEM,
|
||||
schema: developerMetaSchema,
|
||||
extractRefs: () => [],
|
||||
};
|
||||
|
||||
@@ -45,5 +45,4 @@ export const preparerRole: RoleDefinition<PreparerMeta> = {
|
||||
"Locates or clones the target repository, ensures it is up to date, and gathers project context (conventions, toolchain).",
|
||||
systemPrompt: PREPARER_SYSTEM,
|
||||
schema: preparerMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
|
||||
@@ -35,5 +35,4 @@ export const submitterRole: RoleDefinition<SubmitterMeta> = {
|
||||
description: "Pushes the developer's branch to the remote and opens a pull request.",
|
||||
systemPrompt: SUBMITTER_SYSTEM,
|
||||
schema: submitterMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
|
||||
@@ -29,10 +29,7 @@ export function createAgentAdapter<Opt>(
|
||||
return <T>(prompt: string, schema: z.ZodType<T>) => {
|
||||
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
|
||||
const options = await extract(ctx, prompt, runtime);
|
||||
const raw = await (agent as (ctx: ThreadContext, optionsParam: Opt) => Promise<string>)(
|
||||
ctx,
|
||||
options,
|
||||
);
|
||||
const raw = await agent(ctx, options);
|
||||
const contentHash = await putContentNodeWithRefs(runtime.cas, raw, []);
|
||||
const extracted = await runtime.extract(
|
||||
schema as z.ZodType<Record<string, unknown>>,
|
||||
@@ -42,7 +39,3 @@ export function createAgentAdapter<Opt>(
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function createSimpleAgentAdapter(agent: AgentFn<void>): AdapterFn {
|
||||
return createAgentAdapter(agent, async () => undefined as unknown as undefined);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export { buildAgentPrompt, buildThreadInput } from "./build-agent-prompt.js";
|
||||
export type { ExtractOptionsFn } from "./create-agent-adapter.js";
|
||||
export { createAgentAdapter, createSimpleAgentAdapter } from "./create-agent-adapter.js";
|
||||
export type { SpawnCliConfig, SpawnCliError, SpawnCliResult } from "./spawn-cli.js";
|
||||
export { createAgentAdapter } from "./create-agent-adapter.js";
|
||||
export type { SpawnCliError } from "./spawn-cli.js";
|
||||
export { spawnCli } from "./spawn-cli.js";
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
export { err, ok } from "@uncaged/workflow-protocol";
|
||||
export {
|
||||
CROCKFORD_BASE32_ALPHABET,
|
||||
decodeCrockfordBase32Bits,
|
||||
decodeCrockfordToUint64,
|
||||
encodeCrockfordBase32Bits,
|
||||
encodeUint64AsCrockford,
|
||||
} from "./base32.js";
|
||||
export { encodeUint64AsCrockford } from "./base32.js";
|
||||
export { env } from "./env.js";
|
||||
export { createLogger } from "./logger.js";
|
||||
export { mergeRefsWithContentHash, normalizeRefsField } from "./refs-field.js";
|
||||
export { normalizeRefsField } from "./refs-field.js";
|
||||
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
|
||||
export type { CreateLoggerOptions, LogFn, LoggerSink, Result } from "./types.js";
|
||||
export type { LogFn, Result } from "./types.js";
|
||||
export { generateUlid } from "./ulid.js";
|
||||
|
||||
@@ -31,7 +31,6 @@ const roles: WorkflowDefinition<GreetMeta>["roles"] = {
|
||||
systemPrompt:
|
||||
"You are a friendly greeter. Given a user prompt, produce a warm greeting. Respond in valid JSON with keys: greeting (string), language (string).",
|
||||
schema: greeterSchema,
|
||||
extractRefs: null,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user