Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoju 24802f51db fix: address PR review — sessionId guard, resume error logging, atomic cache write
1. Guard against undefined sessionId before writing to cache
2. Log resume failures instead of silent catch
3. Atomic write (temp + rename) for session cache file
4. Add @uncaged/workflow-util dependency to claude-code agent

Refs #418
2026-05-23 08:03:39 +00:00
119 changed files with 842 additions and 5188 deletions
+1 -2
View File
@@ -41,8 +41,7 @@ roles:
Before starting any work, ensure a clean worktree:
1. `git checkout main && git pull` to get the latest code
2. `git checkout -b fix/<issue-number>-<short-description>` to create a fresh branch
- If bounced back from reviewer or tester, reuse the existing branch and rebase onto latest main:
`git checkout main && git pull && git checkout <branch> && git rebase main`
- If bounced back from reviewer or tester, reuse the existing branch instead
Then implement TDD:
3. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
+56 -66
View File
@@ -2,102 +2,92 @@
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions with roles, JSONata routing conditions, and a directed graph. Threads are immutable CAS-linked chains — each `uwf thread step` runs one moderator→agent→extract cycle and exits.
## Overview
## Package Map
This monorepo implements **uwf**, a workflow engine with no long-running daemon. You register YAML workflow definitions in a content-addressed store (CAS), start a thread with an initial prompt, then invoke `uwf thread step` repeatedly until the moderator routes to `$END`. Each step is a complete process: the moderator evaluates JSONata conditions to pick the next role, an external agent CLI produces frontmatter markdown output, and an extract pipeline validates or structures that output against the role's JSON Schema.
| Package | npm | Role |
|---------|-----|------|
| `cli-workflow` | `@uncaged/cli-workflow` | `uwf` CLI binary — thread lifecycle, workflow registry, CAS inspection, setup |
| `workflow-protocol` | `@uncaged/workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `WorkflowConfig`, etc.) |
| `workflow-moderator` | `@uncaged/workflow-moderator` | JSONata graph evaluator — determines next role or `$END` |
| `workflow-agent-kit` | `@uncaged/workflow-agent-kit` | `createAgent` factory, context builder, two-layer extract pipeline |
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | `uwf-hermes` agent — spawns Hermes chat, captures session |
| `workflow-util` | `@uncaged/workflow-util` | Crockford Base32, ULID, logger, frontmatter parsing |
Workflow state lives entirely on disk under `~/.uncaged/workflow/`: CAS nodes for definitions and step payloads, `registry.yaml` for workflow name→hash mappings, and `threads.yaml` for active thread head pointers. Completed threads are archived to `history.jsonl`. Because there is no server process, workflows are easy to debug, fork, and inspect with ordinary CLI tools.
Agents are pluggable CLI binaries (`uwf-hermes`, `uwf-builtin`, `uwf-claude-code`, or custom commands). The engine spawns the configured agent with `<thread-id>` and `<role>`, sets `UWF_EDGE_PROMPT` from the graph transition, and captures both the agent's markdown output and a detail CAS node for session replay.
## Architecture
Dependency layers (lower layers have no dependency on higher layers):
```
Layer 0 — Contract
workflow-protocol Shared types and JSON Schema definitions
Layer 1 — Shared infra
workflow-util Encoding, IDs, logging, frontmatter, paths
workflow-moderator JSONata graph evaluator
Layer 2 — Agent framework
workflow-agent-kit createAgent factory, context builder, extract pipeline
Layer 3 — Agent implementations
workflow-agent-hermes Hermes ACP agent (uwf-hermes)
workflow-agent-builtin Built-in LLM + tools agent (uwf-builtin)
workflow-agent-claude-code Claude Code agent (uwf-claude-code)
Layer 4 — CLI
cli-workflow uwf binary — thread lifecycle, registry, CAS, setup
App (uses protocol; not in the runtime engine stack)
workflow-dashboard Web UI for visual workflow editing
```
External CAS: [`@uncaged/json-cas`](https://www.npmjs.com/package/@uncaged/json-cas) (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend).
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, CAS node types, storage layout, agent CLI protocol, and design decisions.
## Packages
| Package | npm | Description | Type | README |
|---------|-----|-------------|------|--------|
| `cli-workflow` | `@uncaged/cli-workflow` | `uwf` CLI — thread lifecycle, workflow registry, CAS inspection, setup | cli | [README](packages/cli-workflow/README.md) |
| `workflow-protocol` | `@uncaged/workflow-protocol` | Shared TypeScript types and JSON Schema constants | lib | [README](packages/workflow-protocol/README.md) |
| `workflow-moderator` | `@uncaged/workflow-moderator` | JSONata graph evaluator — next role or `$END` | lib | [README](packages/workflow-moderator/README.md) |
| `workflow-agent-kit` | `@uncaged/workflow-agent-kit` | `createAgent` factory, context builder, extract pipeline | lib | [README](packages/workflow-agent-kit/README.md) |
| `workflow-util` | `@uncaged/workflow-util` | Crockford Base32, ULID, logger, frontmatter parsing, storage paths | lib | [README](packages/workflow-util/README.md) |
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | `uwf-hermes` — spawns Hermes chat via ACP | agent | [README](packages/workflow-agent-hermes/README.md) |
| `workflow-agent-builtin` | `@uncaged/workflow-agent-builtin` | `uwf-builtin` — built-in LLM agent with file/shell tools | agent | [README](packages/workflow-agent-builtin/README.md) |
| `workflow-agent-claude-code` | `@uncaged/workflow-agent-claude-code` | `uwf-claude-code` — spawns Claude Code CLI | agent | [README](packages/workflow-agent-claude-code/README.md) |
| `workflow-dashboard` | `@uncaged/workflow-dashboard` | Web graph editor for workflow YAML (private, alpha) | app | [README](packages/workflow-dashboard/README.md) |
External: [`@uncaged/json-cas`](https://www.npmjs.com/package/@uncaged/json-cas) (CAS store + JSON Schema validation) + `@uncaged/json-cas-fs` (filesystem backend).
## Quick Start
```bash
# 1. Configure provider, model, and default agent
# 1. Configure provider and model
uwf setup
# 2. Register a workflow from YAML
uwf workflow put examples/solve-issue.yaml
# 3. Start a thread (creates head pointer; does not execute)
# 3. Start a thread
uwf thread start solve-issue -p "Fix the login redirect bug"
# 4. Execute steps (one at a time, until done)
uwf thread step <thread-id>
```
Use `-c, --count <number>` on `thread step` to run multiple steps in one invocation. Override the agent with `--agent <cmd>`.
## CLI Commands
## CLI Reference
### Thread
Global options: `-V, --version`, `--format <json|yaml>`, `-h, --help`.
| Command | Description |
|---------|-------------|
| `uwf thread start <workflow> -p <prompt>` | Create a thread (no execution) |
| `uwf thread step <thread-id> [--agent <cmd>]` | Execute one moderator→agent→extract cycle |
| `uwf thread show <thread-id>` | Show head pointer and done status |
| `uwf thread list [--all]` | List threads (`--all` includes archived) |
| `uwf thread steps <thread-id>` | List all steps chronologically |
| `uwf thread read <thread-id> [--quota N]` | Render thread as readable markdown |
| `uwf thread fork <step-hash>` | Fork from a specific step |
| `uwf thread step-details <step-hash>` | Dump full detail node |
| `uwf thread kill <thread-id>` | Terminate and archive |
| Group | Commands |
|-------|----------|
| **thread** | `start`, `step`, `show`, `list`, `kill`, `steps`, `read`, `fork`, `step-details` |
| **workflow** | `put`, `show`, `list` |
| **cas** | `get`, `put`, `put-text`, `has`, `refs`, `walk`, `reindex`, `schema list`, `schema get` |
| **setup** | Interactive or `--provider`, `--base-url`, `--api-key`, `--model`, `--agent` |
| **skill** | `cli` — print markdown reference of all uwf commands |
| **log** | `list`, `show`, `clean` — process-level debug logs |
### Workflow
Config is stored in `~/.uncaged/workflow/config.yaml`. API keys go in `~/.uncaged/workflow/.env`.
| Command | Description |
|---------|-------------|
| `uwf workflow put <file.yaml>` | Register a workflow from YAML |
| `uwf workflow show <name-or-hash>` | Show workflow definition |
| `uwf workflow list` | List registered workflows |
Detailed command usage, options, and examples: [packages/cli-workflow/README.md](packages/cli-workflow/README.md).
### CAS
| Command | Description |
|---------|-------------|
| `uwf cas get <hash>` | Read a CAS node |
| `uwf cas put <type-hash> <data>` | Store a node |
| `uwf cas has <hash>` | Check existence |
| `uwf cas refs <hash>` | List direct references |
| `uwf cas walk <hash>` | Recursive traversal |
| `uwf cas reindex` | Rebuild type index |
| `uwf cas schema list` | List schemas |
| `uwf cas schema get <hash>` | Show a schema |
### Setup
| Command | Description |
|---------|-------------|
| `uwf setup` | Interactive provider/model/agent configuration |
| `uwf setup --provider ... --base-url ... --api-key ... --model ...` | Non-interactive setup |
Config stored in `~/.uncaged/workflow/config.yaml`. API keys in `~/.uncaged/workflow/.env`.
## Development
```bash
bun install --no-cache # Install dependencies
bun run build # tsc --build (all packages)
bun run check # tsc + biome + lint-log-tags
bun run format # Auto-format with Biome
bun test # Run all tests
```
Managed with **bun workspace**. See [CLAUDE.md](CLAUDE.md) for coding conventions.
## Architecture
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, CAS node types, storage layout, agent CLI protocol, and design decisions.
-9
View File
@@ -17,15 +17,6 @@
"indentWidth": 2,
"lineWidth": 100
},
"css": {
"parser": {
"cssModules": true,
"tailwindDirectives": true
},
"linter": {
"enabled": false
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
-779
View File
@@ -1,779 +0,0 @@
# Built-in Role Agent 调研
## 目标
实现一个内置的 role agent(暂称 `uwf-builtin`),不依赖 hermes/openclaw 等外部 agent 进程。
直接使用 workflow config 中配置的 model,自己实现 agent run loop 和关键 toolkit。
---
## 关键问题
### Q1: Agent 接口协议
现有 agent 是怎么被 CLI 调用的?输入(argv、环境变量)和输出(stdout、CAS)格式是什么?
**调研要点:**
- `cli-workflow``spawnAgent` 的完整实现
- AgentConfig 类型定义
- agent 进程的 exit code 约定
- 环境变量传递(UWF_STORAGE_ROOT 等)
**答案:**
#### 调用链
`uwf thread step``cmdThreadStepOnce` → moderator 求值下一 role → `resolveAgentConfig``spawnAgent`
#### AgentConfig 类型
```146:149:packages/workflow-protocol/src/types.ts
export type AgentConfig = {
command: string;
args: string[];
};
```
在 `config.yaml` 的 `agents` 段注册,例如 `hermes: { command: "uwf-hermes", args: [] }`。
#### spawnAgent 行为
```627:653:packages/cli-workflow/src/commands/thread.ts
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) {
// ... stderr 拼进 fail 消息
}
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
if (!isCasRef(line)) {
fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
}
return line;
}
```
| 项目 | 约定 |
|------|------|
| **argv** | `[...agent.args, <thread-id>, <role>]`,即 `process.argv[2]`=threadId,`process.argv[3]`=role(与 `createAgent` 的 `parseArgv` 一致) |
| **stdin** | 忽略 |
| **stdout** | 纯文本,**最后一行**必须是新 `StepNode` 的 CAS hash(13 字符 Crockford Base32) |
| **stderr** | 失败时 CLI 会附带 stderr;成功时无约定 |
| **exit code** | `0` = 成功;非 0 时 `execFileSync` 抛错,step 失败 |
| **环境变量** | 继承父进程 `process.env`(含 storage root、API key 等) |
| **链头更新** | **不由 agent 负责**;agent 只写 CAS StepNode,CLI 在拿到 stdout hash 后更新 `threads.yaml` |
Agent 解析优先级(`resolveAgentConfig`):
1. CLI `--agent` override(整段 command + args 字符串)
2. `config.agentOverrides[workflow.name][role]`
3. `config.defaultAgent`
#### 环境变量:Storage Root
文档中写的 `UWF_STORAGE_ROOT` **在当前代码中不存在**。实际优先级(`workflow-agent-kit` / `cli-workflow` 一致):
```33:43:packages/workflow-agent-kit/src/storage.ts
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();
}
```
Agent 子进程通过继承的 `process.env` 与父 CLI 共享同一 storage root;`createAgent` 内还会 `loadDotenv({ path: getEnvPath(storageRoot) })` 加载 `~/.uncaged/workflow/.env`。
#### Agent 侧职责(设计文档 + 实现)
- 读 `threads.yaml` 链头,构建 context,执行 role
- 将 `StepNode` 写入 CAS(`output` / `detail` / `agent` / `prev` / `start`)
- stdout 打印 step hash
- **不**更新 `threads.yaml`
---
### Q2: createAgent 工厂
workflow-agent-kit 的 `createAgent` 做了什么?它的完整生命周期是什么?
**调研要点:**
- `AgentOptions` 类型的 `run` 和 `continue` 回调签名
- `AgentRunResult` 的完整定义
- retry 逻辑(frontmatter 校验失败后的重试机制)
- `persistStep` 写入 CAS 的 StepNode 结构
**答案:**
#### 类型定义
```4:35:packages/workflow-agent-kit/src/types.ts
export type AgentContext = ModeratorContext & {
threadId: ThreadId;
role: string;
store: Store;
workflow: WorkflowPayload;
outputFormatInstruction: string;
};
export type AgentRunResult = {
output: string;
detailHash: CasRef;
sessionId: string;
};
export type AgentContinueFn = (
sessionId: string,
message: string,
store: AgentContext["store"],
) => Promise<AgentRunResult>;
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
export type AgentOptions = {
name: string;
run: AgentRunFn;
continue: AgentContinueFn;
};
```
- **`run(ctx)`**:首次执行,返回原始 agent 文本 `output`、审计用 `detailHash`、用于续聊的 `sessionId`。
- **`continue(sessionId, message, store)`**:在同一 session 上追加用户消息(用于 frontmatter 纠错),再次返回 `AgentRunResult`。
`createAgent(options)` 返回 `() => Promise<void>`,作为 agent CLI 的 `main`(见 `uwf-hermes` 的 `cli.ts`)。
#### 生命周期(按执行顺序)
```101:152:packages/workflow-agent-kit/src/run.ts
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 buildContextWithMeta(threadId, role);
// 1. 校验 role 存在
// 2. 从 CAS 取 frontmatter JSON Schema → buildOutputFormatInstruction → ctx.outputFormatInstruction
let agentResult = await options.run(ctx);
let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && outputHash === null; retry++) {
const correctionMessage = "Your previous response did not contain valid YAML frontmatter...";
agentResult = await options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store);
outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
}
if (outputHash === null) { fail(...); }
const stepHash = await persistStep({ ctx, outputHash, detailHash: agentResult.detailHash, agentName });
process.stdout.write(`${stepHash}\n`);
};
}
```
| 阶段 | 行为 |
|------|------|
| 解析 argv | `argv[2]=threadId`, `argv[3]=role`,缺失则 `stderr` + `exit(1)` |
| Context | `buildContextWithMeta` + 可选 `outputFormatInstruction` |
| Run | `options.run(ctx)` |
| Extract | **仅** `tryFrontmatterFastPath`(见 Q4);**不**调用 `extract()` LLM fallback |
| Retry | 最多 `MAX_FRONTMATTER_RETRIES = 2` 次 `continue` + 再试 fast-path |
| Persist | `persistStep` → `writeStepNode` |
| 输出 | stdout 一行 step CAS hash |
#### StepNode 写入结构
```44:68:packages/workflow-agent-kit/src/run.ts
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,
};
// store.put(stepNode schema) + validate
}
```
`agentName` 经 `agentLabel(name)` 规范化:已有 `uwf-` 前缀则原样,否则加 `uwf-`(如 `hermes` → `uwf-hermes`)。
`prevHash`:若链头仍是 `StartNode` 则为 `null`,否则为当前 head step hash。
---
### Q3: Context Builder
`buildContextWithMeta` 构建了什么上下文给 agent?
**调研要点:**
- `AgentContext` 完整类型定义(所有字段)
- context 构建过程(CAS chain walk)
- `outputFormatInstruction` 怎么生成的
- role definition 怎么获取(从 workflow YAML)
**答案:**
#### AgentContext 字段
继承 `ModeratorContext`:
```60:68:packages/workflow-protocol/src/types.ts
export type ModeratorContext = {
start: StartNodePayload;
steps: StepContext[];
};
```
```48:51:packages/workflow-protocol/src/types.ts
export type StartNodePayload = {
workflow: CasRef;
prompt: string;
};
```
```61:63:packages/workflow-protocol/src/types.ts
export type StepContext = Omit<StepRecord, "output"> & {
output: unknown;
};
```
`AgentContext` 额外字段:
| 字段 | 类型 | 含义 |
|------|------|------|
| `threadId` | `ThreadId` | 当前线程 |
| `role` | `string` | 本步要执行的角色名 |
| `store` | `Store` | CAS store(读写节点) |
| `workflow` | `WorkflowPayload` | 已从 CAS 加载的 workflow 定义 |
| `outputFormatInstruction` | `string` | 由 `createAgent` 根据 role 的 frontmatter schema 生成;`buildContext*` 初始为 `""` |
`buildContextWithMeta` 还返回 `meta`:
```148:154:packages/workflow-agent-kit/src/context.ts
export type BuildContextMeta = {
storageRoot: string;
store: Store;
schemas: AgentStore["schemas"];
headHash: CasRef;
chain: ChainState;
};
```
#### CAS chain walk
1. 从 `threads.yaml[threadId]` 取 `headHash`
2. `walkChain`:若 head 是 `StartNode`,`stepsNewestFirst=[]`;否则沿 `prev` 收集所有 `StepNode`, newest-first
3. `buildHistory`:反转为时间序,`expandOutput` 把每步 `output` CasRef 展开为 JSON payload(供 prompt / JSONata 使用)
4. `loadWorkflow`:从 `start.workflow` CasRef 加载 `WorkflowPayload`
#### Role definition 来源
- 作者写在 workflow YAML 的 `roles.<name>`(`goal`, `capabilities`, `procedure`, `output`, `frontmatter` 等)
- `uwf workflow put` 时 `frontmatter` 内联 JSON Schema 经 `putSchema` 存入 CAS,workflow 里存的是 **CasRef**
- Agent 运行时:`ctx.workflow.roles[ctx.role]` → `RoleDefinition`
#### outputFormatInstruction
在 `createAgent` 中,若 `getSchema(store, roleDef.frontmatter)` 非空,则:
```typescript
ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema);
```
`buildOutputFormatInstruction` 根据 JSON Schema 的 `properties` 生成「必须以 `---` YAML frontmatter 开头」的说明和示例字段列表(见 `build-output-format-instruction.ts`)。
各 agent 实现(Hermes / Claude Code)在组装 prompt 时把该块放在最前,再接 `buildRolePrompt(roleDef)`。
---
### Q4: Extract Pipeline
agent 输出怎么被处理成结构化数据?
**调研要点:**
- frontmatter fast-path 的完整逻辑
- LLM extract fallback 的实现(`extract.ts`)
- frontmatter schema 从哪里来(role 定义里的 `frontmatter` 字段)
- 校验失败时的 correction prompt 是什么
**答案:**
#### Schema 来源
Workflow YAML 中每个 role 的 `frontmatter:` 段是 JSON Schema 对象;注册时:
```66:76:packages/cli-workflow/src/commands/workflow.ts
async function resolveFrontmatterRef(..., frontmatter: unknown): Promise<CasRef> {
// 校验为 JSON Schema → putSchema → 返回 CasRef
}
```
运行时 `roleDef.frontmatter` 即该 schema 的 CAS hash;structured `output` 节点用**同一 schema** 写入 CAS。
#### Frontmatter fast-path(createAgent 实际使用的路径)
```148:195:packages/workflow-agent-kit/src/frontmatter.ts
export async function tryFrontmatterFastPath(
raw: string,
outputSchema: CasRef,
store: Store,
): Promise<FrontmatterFastPathResult | null>
```
流程:
1. `parseFrontmatterMarkdown(raw)` → 标准 agent 字段(`status`, `next`, `confidence`, `artifacts`, `scope`)+ body
2. `validateFrontmatter` 失败 → `null`
3. `getSchema(store, outputSchema)` + `extractSchemaFields` 得到 role 需要的属性名
4. `buildCandidate`:从标准 frontmatter + YAML 原始字段拼出符合 schema 的对象
5. `store.put(outputSchema, candidate)` + `validate` → 成功则 `{ body, outputHash }`
**永不抛错**,失败返回 `null`。
#### LLM extract fallback(已实现但未接入 createAgent)
```135:181:packages/workflow-agent-kit/src/extract.ts
export async function extract(
rawOutput: string,
outputSchema: CasRef,
config: WorkflowConfig,
): Promise<ExtractResult>
```
- 模型:`resolveExtractModelAlias(config)` → `modelOverrides.extract` → `models.extract` → `models.default` → `defaultModel`
- HTTP:`POST {baseUrl}/chat/completions`,`response_format: { type: "json_object" }`
- System:要求按 JSON Schema 从 agent 输出提取单个 JSON 对象
- 校验通过后 `store.put(outputSchema, structured)`
**重要:`createAgent` 当前未调用 `extract()`**。fast-path 失败且 2 次 `continue` 仍失败则直接 `fail()`。builtin agent 若希望无 frontmatter 也能跑,需在 kit 或 builtin 层显式接入 `extract()`。
#### Correction prompt(retry)
```125:128:packages/workflow-agent-kit/src/run.ts
const correctionMessage =
"Your previous response did not contain valid YAML frontmatter matching the role schema.\n" +
"You MUST begin your response with a YAML frontmatter block (--- delimited).\n" +
"Please output ONLY the corrected frontmatter block followed by your work.";
```
通过 `options.continue(sessionId, correctionMessage, store)` 发给外部 agent;builtin 需在自有 message 历史里 append 同等语义的 user 消息。
---
### Q5: Model 配置与 LLM 调用
workflow 怎么配置和使用 model?
**调研要点:**
- `WorkflowConfig` 中 providers/models/defaultModel/modelOverrides 的完整定义
- `resolveModel` 函数的实现
- `chatCompletionText` 的实现(OpenAI 兼容 HTTP 客户端)
- 有没有 streaming 支持?tool calling 支持?
**答案:**
#### WorkflowConfig
```136:160:packages/workflow-protocol/src/types.ts
export type ProviderConfig = {
baseUrl: string;
apiKeyEnv: string;
};
export type ModelConfig = {
provider: ProviderAlias;
name: string;
};
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;
};
```
示例见 `docs/architecture.md`(`providers` / `models` / `defaultModel` / `modelOverrides.extract`)。
#### resolveModel
```32:50:packages/workflow-agent-kit/src/extract.ts
export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider {
const modelEntry = config.models[alias];
const providerEntry = config.providers[modelEntry.provider];
const apiKey = process.env[providerEntry.apiKeyEnv];
return { baseUrl: providerEntry.baseUrl, apiKey, model: modelEntry.name };
}
```
`ResolvedLlmProvider = { baseUrl, apiKey, model }`。
Extract 专用别名解析:
```18:30:packages/workflow-agent-kit/src/extract.ts
export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
return config.modelOverrides?.extract ?? (config.models.extract ? "extract" : config.models.default ? "default" : config.defaultModel);
}
```
**尚无** `modelOverrides` 按 role/workflow 解析 agent 主模型的函数;builtin 首版可用 `config.defaultModel`,扩展时可加 `modelOverrides.agent` 或与 `agentOverrides` 对称的表。
#### chatCompletionText
```87:124:packages/workflow-agent-kit/src/extract.ts
async function chatCompletionText(
provider: ResolvedLlmProvider,
messages: Array<{ role: "system" | "user"; content: string }>,
): Promise<string>
```
| 能力 | 现状 |
|------|------|
| 协议 | OpenAI 兼容 `POST /chat/completions` |
| Streaming | **无**(一次性 `response.text()`) |
| Tool calling | **无**(无 `tools` / `tool_calls` 字段) |
| 多模态 | **无**(仅 text `content`) |
| Extract 专用 | `response_format: { type: "json_object" }` |
builtin agent 的 run loop 需要**新写**带 `tools` 的 completion 客户端(可放在 `workflow-agent-builtin` 或扩展 `workflow-agent-kit` 的 `llm/` 模块),不能复用当前 `chatCompletionText` 而不改。
---
### Q6: Hermes Agent 参考实现
`uwf-hermes` 是怎么实现 `run` 和 `continue` 的?
**调研要点:**
- prompt 怎么组装的(outputFormatInstruction + rolePrompt + task + history)
- hermes CLI 的调用参数
- session management(resume)
- 输出怎么捕获
**答案:**
#### Prompt 组装
```40:53:packages/workflow-agent-hermes/src/hermes.ts
export function buildHermesPrompt(ctx: AgentContext): string {
const roleDef = ctx.workflow.roles[ctx.role];
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
const parts: string[] = [];
if (ctx.outputFormatInstruction !== "") {
parts.push(ctx.outputFormatInstruction, "");
}
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
const historyBlock = buildHistorySummary(ctx.steps);
if (historyBlock !== "") {
parts.push("", historyBlock);
}
return parts.join("\n");
}
```
`buildRolePrompt` 生成 `## Goal` / `## Capabilities` / `## Prepare`(含 `generateCliReference()`)/ `## Procedure` / `## Output`。
`buildHistorySummary`:每步 `role`、`JSON.stringify(step.output)`、`agent`。
Hermes 把**整段 prompt 作为单条 user 消息**传给 `hermes chat -q`(无独立 system channel)。
#### Hermes CLI 参数
首次:
```88:97:packages/workflow-agent-hermes/src/hermes.ts
spawnHermes(["chat", "-q", prompt, "--yolo", "--max-turns", "90", "--quiet"]);
```
续聊:
```100:114:packages/workflow-agent-hermes/src/hermes.ts
spawnHermes(["chat", "--resume", sessionId, "-q", message, "--yolo", "--max-turns", "90", "--quiet"]);
```
#### Session
- stdout/stderr 中解析 `session_id: <id>`(`parseSessionIdFromStdout`)
- 会话文件:`~/.hermes/sessions/session_<id>.json`
- `loadHermesSession` → `storeHermesSessionDetail`:每 assistant/tool 消息写成 CAS turn 节点,汇总为 `detail`;**output 文本** = 最后一条非空 `assistant` 的 `content`
#### 与 createAgent 的衔接
```157:164:packages/workflow-agent-hermes/src/hermes.ts
export function createHermesAgent(): () => Promise<void> {
return createAgent({ name: "hermes", run: runHermes, continue: continueHermes });
}
```
`uwf-hermes` 入口:`createHermesAgent()` 即 main。
Claude Code 包(`workflow-agent-claude-code`)结构相同:`buildClaudeCodePrompt` 同构,`claude -p` + `--resume` + JSON stdout 解析。
---
### Q7: Toolkit 需求分析
要实现一个自给自足的 agent,最少需要哪些 tool?
**调研要点:**
- 现有 workflow example(solve-issue.yaml)里 role 都做什么任务
- hermes agent 在 workflow 场景下常用哪些 tool
- 哪些 tool 是 agent loop 必须的(如 file read/write、shell exec、web fetch)
**答案:**
#### solve-issue.yaml 角色能力
| Role | capabilities | 隐含需求 |
|------|----------------|----------|
| planner | issue-analysis, planning | 读上下文/仓库、总结,通常不需写代码 |
| developer | file-edit, shell, testing | **读文件、写文件、执行命令** |
| reviewer | code-review, static-analysis | 读 diff/文件、静态分析(可读+可选 shell) |
#### Hermes 侧
Hermes 自带完整 agent runtime(`--yolo`、max-turns),tool 集由 Hermes 项目定义,workflow 不配置。从 session JSON 可见 `tool_calls` 被记入 detail,常见包括文件与 shell 类工具。
#### Builtin 最小 toolkit 建议
| 优先级 | Tool | 用途 |
|--------|------|------|
| P0 | `read_file` | 读仓库/配置/issue 上下文 |
| P0 | `write_file` / `edit_file` | developer 改代码 |
| P0 | `run_command` | 测试、构建、git(需 cwd + timeout + 输出截断) |
| P1 | `list_dir` / `glob` | 导航代码库 |
| P1 | `grep` | 搜索符号/引用 |
| P2 | `fetch_url` | 查文档(planner 偶尔需要) |
**不需要**在 builtin 里实现 moderator / workflow 路由工具——仍由 `uwf thread step` + JSONata 负责。
#### Agent loop 必须能力
1. 多轮 LLM 调用 + **OpenAI-style tool_calls** 解析与执行
2. 将 tool 结果 append 回 messages
3. 终止条件:模型不再请求 tool,或达到 `maxTurns`
4. 最终响应须含合法 YAML frontmatter(满足 Q4),供 `createAgent` fast-path
---
## 方案草案
(调研完成后基于以上答案撰写)
### 架构设计
```mermaid
flowchart TB
subgraph cli ["cli-workflow"]
Step["uwf thread step"]
Spawn["spawnAgent(uwf-builtin, threadId, role)"]
Step --> Spawn
end
subgraph builtin_pkg ["@uncaged/workflow-agent-builtin"]
Main["createBuiltinAgent() = createAgent({...})"]
Prompt["buildBuiltinPrompt(ctx)"]
Loop["runBuiltinLoop(provider, messages, tools)"]
Tools["Toolkit: read/write/exec/..."]
Detail["storeBuiltinDetail(turns)"]
Main --> Prompt
Main --> Loop
Loop --> Tools
Loop --> Detail
end
subgraph kit ["workflow-agent-kit"]
Ctx["buildContextWithMeta"]
FM["tryFrontmatterFastPath"]
Persist["persistStep"]
Ctx --> Main
Main --> FM
FM --> Persist
end
subgraph cas ["CAS / config"]
Config["config.yaml models/providers"]
CAS["cas/ + threads.yaml"]
end
Spawn --> Main
Config --> Loop
CAS --> Ctx
Persist --> CAS
Spawn -->|"stdout: step hash"| Step
```
**新包**:`packages/workflow-agent-builtin`,bin `uwf-builtin`,仅依赖 `workflow-agent-kit`、`workflow-protocol`、`workflow-util`(可选 `@uncaged/json-cas` 写 detail schema)。
**分层**:
| 层 | 职责 |
|----|------|
| `createAgent`(kit) | argv、context、frontmatter extract、StepNode、stdout 协议 — **不变** |
| `builtin/agent.ts` | `run` / `continue` 实现 |
| `builtin/llm.ts` | OpenAI 兼容 chat + tools(可后续抽到 kit) |
| `builtin/tools/*.ts` | 各 tool 的 JSON Schema + handler |
| `builtin/prompt.ts` | 复用 Hermes 的 prompt 拼接逻辑(或抽到 kit 的 `buildAgentPrompt`) |
| `builtin/detail.ts` | 类似 Hermes:每轮 assistant/tool 写入 CAS detail |
**配置集成**:
```yaml
agents:
builtin:
command: "uwf-builtin"
args: []
defaultAgent: "builtin" # 或 agentOverrides 按 role 指定
```
模型:首版 `resolveModel(config, config.defaultModel)`;后续可增加 `modelOverrides.agent` 或 per-role 映射。
---
### Agent Run Loop
伪代码(单次 `run(ctx)`):
```
1. provider ← resolveModel(loadWorkflowConfig(), defaultModel)
2. system ← buildBuiltinPrompt(ctx) // outputFormatInstruction + buildRolePrompt + Task + History
3. messages ← [{ role: "system", content: system }]
4. sessionId ← newULID() // 内存或临时目录,供 continue 使用
5. turns ← []
6. for turn in 1..MAX_TURNS:
response ← chatCompletionWithTools(provider, messages, TOOL_DEFINITIONS)
record assistant message + tool_calls in turns
if response has no tool_calls:
finalText ← response.content
break
for each tool_call:
result ← executeTool(tool_call, { cwd: process.cwd() })
messages.push tool result
record in turns
7. if no finalText with valid frontmatter after loop:
optionally one-shot "finalize" message without tools
8. detailHash ← storeBuiltinDetail(store, sessionId, turns, metadata)
9. return { output: finalText, detailHash, sessionId }
```
**`continue(sessionId, message, store)`**:
- 从内存/磁盘恢复 `messages` + `turns`
- `messages.push({ role: "user", content: message })`(correction 或续聊)
- 从步骤 6 继续,步数上限可单独设小一点(如 3)
- 返回新的 `AgentRunResult`
**与 frontmatter 的配合**:
- system prompt 已含 `outputFormatInstruction`;最后一轮可强制 user:`Now output your final answer with YAML frontmatter only if you have not yet.`
- 仍依赖 `createAgent` 的 fast-path + 最多 2 次 continue
**安全**:
- `run_command`:白名单或需 `UWF_BUILTIN_ALLOW_SHELL=1`,默认工作区限定在 `process.cwd()` 或 `start` 中将来扩展的 `workspace` 字段
- 路径:禁止 `..` 逃逸出 workspace root
---
### Toolkit 设计
统一注册表:
```typescript
type BuiltinTool = {
name: string;
description: string;
parameters: JSONSchema; // object type
execute: (args: unknown, ctx: ToolContext) => Promise<string>;
};
type ToolContext = {
cwd: string;
storageRoot: string;
};
```
| Tool name | OpenAI function | 行为摘要 |
|-----------|-----------------|----------|
| `read_file` | `read_file` | `{ path }` → UTF-8 文本,大小上限 |
| `write_file` | `write_file` | `{ path, content }` → 写盘,返回确认 |
| `edit_file` | 可选 | search/replace 块,减少 token |
| `run_command` | `run_command` | `{ command, cwd? }` → stdout/stderr 截断 |
| `list_dir` | `list_dir` | `{ path }` → 条目列表 |
| `grep` | `grep` | `{ pattern, path? }` → 匹配行 |
**LLM 请求形状**(扩展 extract 客户端):
```json
{
"model": "...",
"messages": [...],
"tools": [{ "type": "function", "function": { "name", "description", "parameters" } }],
"tool_choice": "auto"
}
```
解析 `choices[0].message.tool_calls`,执行后以 `{ role: "tool", tool_call_id, content }` 回传。
**不提供** streaming 首版;detail CAS 记录每轮 tool 名/参数/结果摘要供 `uwf thread step-details` 调试。
---
### 与现有架构的集成
| 集成点 | 方式 |
|--------|------|
| CLI 协议 | 实现标准 agent CLI:`uwf-builtin <thread-id> <role>`,stdout 一行 step hash,exit 0/1 |
| 工厂 | `export function createBuiltinAgent()` → `createAgent({ name: "builtin", run, continue })` |
| Context / Prompt | 复用 `buildContextWithMeta`、`buildRolePrompt`、`buildOutputFormatInstruction`;prompt 布局对齐 `buildHermesPrompt` |
| 结构化输出 | 优先 YAML frontmatter fast-path;可选后续在 `createAgent` 增加 `extract()` fallback 开关 |
| 配置 | `config.yaml` 增加 `agents.builtin`;`uwf setup` 可选默认 agent |
| 存储 | `resolveStorageRoot()` + `loadWorkflowConfig` + `getEnvPath`;与 Hermes 相同,**不**改 `threads.yaml` 写入方 |
| 测试 | 单元测试:tool handlers、prompt 组装、mock LLM tool loop;集成测试:临时 storage root + fake provider |
| 发布 | 新包 `@uncaged/workflow-agent-builtin`,bin `uwf-builtin`,加入 `scripts/publish-all.mjs` |
**明确不做**:
- 不替代 moderator / 不在 agent 内调用 `uwf thread step`
- 不依赖 Hermes/OpenClaw/Claude Code 二进制
- 首版不实现 streaming、不实现 MCP
**建议实现顺序**:
1. `llm.ts`:tool calling HTTP 客户端 + 单测
2. P0 tools + `runBuiltinLoop`
3. `createBuiltinAgent` + detail CAS
4. `config` / docs / `examples` 可选 `agentOverrides` 演示
5. (可选)`createAgent` 接入 `extract()` fallback
+7 -1
View File
@@ -56,6 +56,9 @@ conditions:
forConceded:
description: "The for side conceded"
expression: "$last('for').conceded = true"
moreRounds:
description: "Fewer than 3 rounds completed per side"
expression: "$count(steps[role = 'against']) < 3"
graph:
$START:
- role: "against"
@@ -73,5 +76,8 @@ graph:
condition: "forConceded"
prompt: "The for side conceded. Debate over."
- role: "against"
condition: null
condition: "moreRounds"
prompt: "Counter the opposing argument. Address their points directly."
- role: "$END"
condition: null
prompt: "Maximum rounds reached. Debate over."
+9 -27
View File
@@ -3,35 +3,22 @@ description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
goal: "You are a planning agent. You analyze issues and create implementation plans grounded in the actual codebase."
goal: "You are a planning agent. You analyze issues and create step-by-step plans."
capabilities:
- issue-analysis
- planning
- file-read
- shell
procedure: |
1. Locate the code repository:
- Check if the current working directory is the repo (look for package.json, .git, etc.)
- If the task mentions a repo URL, clone it first.
- If this is a new project, create the repo and note the path.
2. Explore the codebase — read the relevant source files mentioned in the issue. Understand the current architecture, types, and conventions (check CLAUDE.md, CONTRIBUTING.md, .cursor/rules/).
3. Identify which files need changes and what the changes should be, with specific code references.
4. Output the plan with:
- `repoPath`: absolute path to the repository root
- `plan`: detailed implementation plan with file paths and code references
- `steps`: concrete action items for the developer
output: |
Provide repoPath, plan summary, and steps in the frontmatter.
The plan MUST reference actual file paths and code structures you found by reading the source.
Do NOT guess — if you haven't read a file, read it before referencing it.
procedure: "Analyze the issue and create a detailed, actionable implementation plan."
output: "Output the plan summary and list of concrete steps."
frontmatter:
type: object
properties:
repoPath:
type: string
plan:
type: string
required: [repoPath, plan]
steps:
type: array
items:
type: string
required: [plan, steps]
developer:
description: "Implements code changes"
goal: "You are a developer agent. You implement code changes according to plans."
@@ -39,12 +26,7 @@ roles:
- file-edit
- shell
- testing
procedure: |
1. Read the planner's output to get the repoPath and implementation plan.
2. cd to the repoPath before making any changes.
3. Create a feature branch from the default branch.
4. Implement the plan — write code, tests, and ensure existing tests pass.
5. Commit your changes with a descriptive message referencing the issue.
procedure: "Implement the plan. Write code, tests, and ensure existing tests pass."
output: "List all files changed and provide a summary of the implementation."
frontmatter:
type: object
+1 -1
View File
@@ -9,7 +9,7 @@
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
"typecheck": "bunx tsc --build",
"format": "biome format --write .",
"test": "bun run --filter './packages/*' test",
"test": "bun run --filter '*' test",
"changeset": "bunx changeset",
"version": "bunx changeset version",
"release": "bun run build && bun test && node scripts/publish-all.mjs"
-128
View File
@@ -1,128 +0,0 @@
# @uncaged/cli-workflow
`uwf` CLI — thread lifecycle, workflow registry, CAS inspection, and setup.
## Overview
Layer 4 entry point for the workflow engine. The `uwf` binary orchestrates one step per invocation: load thread head from `threads.yaml`, run the moderator, spawn the configured agent CLI, run extract, append a CAS step node, and update the head pointer (or archive when `$END`).
This package has no library `src/index.ts` — it is consumed as a CLI binary only.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`, `@uncaged/workflow-agent-kit`, `@uncaged/workflow-moderator`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `commander`, `dotenv`, `yaml`
## Installation
Included as the `uwf` binary when you install `@uncaged/cli-workflow`:
```bash
bun add -g @uncaged/cli-workflow
# or from the monorepo:
bun link packages/cli-workflow
```
## CLI Usage
### Global options
```
-V, --version Show version
--format <json|yaml> Output format (default: json)
-h, --help Show help
```
### Thread
| Command | Description |
|---------|-------------|
| `uwf thread start <workflow> -p <prompt>` | Create a thread without executing |
| `uwf thread step <thread-id> [--agent <cmd>] [-c <count>]` | Execute one or more moderator→agent→extract cycles |
| `uwf thread show <thread-id>` | Show thread head pointer |
| `uwf thread list [--all]` | List active threads (`--all` includes archived) |
| `uwf thread steps <thread-id>` | List all steps chronologically |
| `uwf thread read <thread-id> [--quota N] [--before <hash>] [--start]` | Render thread as readable markdown |
| `uwf thread fork <step-hash>` | Fork from a specific step |
| `uwf thread step-details <step-hash>` | Dump full detail node as YAML |
| `uwf thread kill <thread-id>` | Terminate and archive |
Examples:
```bash
uwf thread start solve-issue -p "Fix the login redirect bug"
uwf thread step 01ARZ3NDEKTSV4RRFFQ69G5FAV
uwf thread step 01ARZ3NDEKTSV4RRFFQ69G5FAV -c 3 --agent uwf-builtin
uwf thread read 01ARZ3NDEKTSV4RRFFQ69G5FAV --quota 8000
```
### Workflow
| Command | Description |
|---------|-------------|
| `uwf workflow put <file.yaml>` | Register a workflow from YAML |
| `uwf workflow show <name-or-hash>` | Show workflow definition |
| `uwf workflow list` | List registered workflows |
### CAS
| Command | Description |
|---------|-------------|
| `uwf cas get <hash> [--timestamp]` | Read a CAS node |
| `uwf cas put <type-hash> <data>` | Store a node, print hash |
| `uwf cas put-text <text>` | Store plain text, print hash |
| `uwf cas has <hash>` | Check existence |
| `uwf cas refs <hash>` | List direct references |
| `uwf cas walk <hash>` | Recursive traversal |
| `uwf cas reindex` | Rebuild type index |
| `uwf cas schema list` | List registered schemas |
| `uwf cas schema get <hash>` | Show a schema |
### Setup
```bash
uwf setup
uwf setup --provider openai --base-url https://api.openai.com/v1 \
--api-key sk-... --model gpt-4o --agent hermes
```
Config: `~/.uncaged/workflow/config.yaml`. API keys: `~/.uncaged/workflow/.env`.
### Skill
| Command | Description |
|---------|-------------|
| `uwf skill cli` | Print markdown reference of all uwf commands (for agent skills) |
### Log
| Command | Description |
|---------|-------------|
| `uwf log list` | List log files with sizes |
| `uwf log show [--thread <id>] [--process <pid>] [--date YYYY-MM-DD]` | Show filtered log entries |
| `uwf log clean [--before YYYY-MM-DD]` | Delete old log files |
## Internal Structure
```
src/
├── cli.ts Commander entrypoint, command registration
├── format.ts JSON/YAML output formatting
├── store.ts CAS store + registry initialization
├── validate.ts Workflow YAML validation
├── schemas.ts CLI-local schema registration
└── commands/
├── thread.ts Thread lifecycle and step execution
├── workflow.ts Workflow registry (put/show/list)
├── cas.ts CAS inspection and schema ops
├── setup.ts Interactive/non-interactive setup
├── skill.ts Built-in skill references
└── log.ts Process debug log management
```
## Configuration
| File | Purpose |
|------|---------|
| `~/.uncaged/workflow/config.yaml` | Providers, models, default agent |
| `~/.uncaged/workflow/.env` | API keys (referenced by `apiKeyEnv` in config) |
| `~/.uncaged/workflow/registry.yaml` | Workflow name → CAS hash |
| `~/.uncaged/workflow/threads.yaml` | Active thread head pointers |
| `~/.uncaged/workflow/cas/` | Content-addressed node storage |
@@ -62,9 +62,9 @@ const olderEntry = JSON.stringify({
async function writeLogFiles(): Promise<void> {
const logsDir = join(storageRoot, "logs");
await writeFile(join(logsDir, "2026-05-20.jsonl"), `${[entry1, entry2, entry3].join("\n")}\n`);
await writeFile(join(logsDir, "2026-05-19.jsonl"), `${oldEntry}\n`);
await writeFile(join(logsDir, "2026-05-18.jsonl"), `${olderEntry}\n`);
await writeFile(join(logsDir, "2026-05-20.jsonl"), [entry1, entry2, entry3].join("\n") + "\n");
await writeFile(join(logsDir, "2026-05-19.jsonl"), oldEntry + "\n");
await writeFile(join(logsDir, "2026-05-18.jsonl"), olderEntry + "\n");
}
describe("cmdLogList", () => {
@@ -266,7 +266,12 @@ describe("cmdThreadRead ### Content section", () => {
expect(markdown).toContain("### Content");
expect(markdown).toContain("The assistant response text");
expect(markdown).not.toContain("### Output");
const contentIdx = markdown.indexOf("### Content");
const outputIdx = markdown.indexOf("### Output");
expect(contentIdx).toBeGreaterThanOrEqual(0);
expect(outputIdx).toBeGreaterThanOrEqual(0);
expect(contentIdx).toBeLessThan(outputIdx);
});
test("omits ### Content when detail has no matching assistant turns", async () => {
@@ -309,7 +314,7 @@ describe("cmdThreadRead ### Content section", () => {
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
expect(markdown).not.toContain("### Content");
expect(markdown).not.toContain("### Output");
expect(markdown).toContain("### Output");
});
});
@@ -387,87 +392,3 @@ describe("cmdThreadStepDetails", () => {
await expect(cmdThreadStepDetails(tmpDir, "nonexistenth0" as CasRef)).rejects.toThrow();
});
});
// ── cmdThreadRead: ### Prompt deduplication ───────────────────────────────────
describe("cmdThreadRead ### Prompt deduplication", () => {
async function makeThreadWithRoles(uwf: UwfStore, roles: string[]): Promise<string> {
const roleMap: Record<string, unknown> = {};
for (const r of [...new Set(roles)]) {
roleMap[r] = {
description: r,
goal: `Goal for ${r}`,
capabilities: [],
procedure: "Do stuff.",
output: "Output.",
meta: "placeholder00" as CasRef,
};
}
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
name: "dedup-wf",
description: "desc",
roles: roleMap,
conditions: {},
graph: {},
});
const startHash = await uwf.store.put(uwf.schemas.startNode, {
workflow: workflowHash,
prompt: "Start",
});
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
name: "out",
description: "",
roles: {},
conditions: {},
graph: {},
});
let prev: string | null = null;
let stepHash = "";
for (const role of roles) {
stepHash = await uwf.store.put(uwf.schemas.stepNode, {
start: startHash,
prev: prev as CasRef | null,
role,
output: outputHash,
detail: null,
agent: "uwf-test",
});
prev = stepHash;
}
return stepHash;
}
test("same consecutive role shows ### Prompt once", async () => {
const uwf = await makeUwfStore(tmpDir);
const headHash = await makeThreadWithRoles(uwf, ["writer", "writer"]);
const threadId = "01JTEST0000000000000003" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
const count = (markdown.match(/### Prompt/g) ?? []).length;
expect(count).toBe(1);
});
test("different consecutive roles each show ### Prompt", async () => {
const uwf = await makeUwfStore(tmpDir);
const headHash = await makeThreadWithRoles(uwf, ["planner", "coder"]);
const threadId = "01JTEST0000000000000004" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
const count = (markdown.match(/### Prompt/g) ?? []).length;
expect(count).toBe(2);
});
test("non-consecutive same role shows ### Prompt twice", async () => {
const uwf = await makeUwfStore(tmpDir);
const headHash = await makeThreadWithRoles(uwf, ["roleA", "roleB", "roleA"]);
const threadId = "01JTEST0000000000000005" as ThreadId;
await saveThreadsIndex(tmpDir, { [threadId]: headHash });
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
const count = (markdown.match(/### Prompt/g) ?? []).length;
expect(count).toBe(2);
});
});
@@ -1,367 +0,0 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, WorkflowPayload } from "@uncaged/workflow-protocol";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { stringify } from "yaml";
import { cmdThreadStart } from "../commands/thread.js";
import { registerUwfSchemas } from "../schemas.js";
import type { UwfStore } from "../store.js";
import { loadWorkflowRegistry, saveWorkflowRegistry } from "../store.js";
// ── helpers ───────────────────────────────────────────────────────────────────
async function makeUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = join(storageRoot, "cas");
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
return { storageRoot, store, schemas };
}
async function storeWorkflow(uwf: UwfStore, name: string): Promise<CasRef> {
const payload: WorkflowPayload = {
name,
description: "Test workflow",
roles: {},
conditions: {},
graph: {},
};
return await uwf.store.put(uwf.schemas.workflow, payload);
}
async function createWorkflowYaml(name: string, version: string | null = null): Promise<string> {
const payload: WorkflowPayload = {
name,
description: version !== null ? `Test workflow (${version})` : "Test workflow",
roles: {},
conditions: {},
graph: {},
};
const yaml = stringify(payload);
return yaml;
}
// ── fixture ───────────────────────────────────────────────────────────────────
let tmpDir: string;
let storageRoot: string;
let projectRoot: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-wf-resolve-test-"));
storageRoot = join(tmpDir, "storage");
projectRoot = join(tmpDir, "project");
await mkdir(storageRoot, { recursive: true });
await mkdir(projectRoot, { recursive: true });
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ── Strategy 1: CAS Hash Resolution ───────────────────────────────────────────
describe("Strategy 1: CAS Hash Resolution", () => {
test("should resolve valid 13-char Crockford Base32 hash", async () => {
const uwf = await makeUwfStore(storageRoot);
const hash = await storeWorkflow(uwf, "test-workflow");
const result = await cmdThreadStart(storageRoot, hash, "test prompt", projectRoot);
expect(result.workflow).toBe(hash);
expect(result.thread).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
});
test("should fail on invalid hash format (non-Crockford characters)", async () => {
await makeUwfStore(storageRoot);
await expect(
cmdThreadStart(storageRoot, "123456789ABCD", "prompt", projectRoot),
).rejects.toThrow();
});
test("should fail on valid-format hash not present in CAS", async () => {
await makeUwfStore(storageRoot);
const fakeHash = "0000000000000"; // valid format, doesn't exist
await expect(cmdThreadStart(storageRoot, fakeHash, "prompt", projectRoot)).rejects.toThrow();
});
test("should reject 40-char hex hash (legacy format not supported)", async () => {
await makeUwfStore(storageRoot);
const hexHash = "a".repeat(40);
await expect(cmdThreadStart(storageRoot, hexHash, "prompt", projectRoot)).rejects.toThrow();
});
});
// ── Strategy 2: File Path Resolution ──────────────────────────────────────────
describe("Strategy 2: File Path Resolution", () => {
test("should load workflow from absolute file path", async () => {
await makeUwfStore(storageRoot);
const yamlPath = join(tmpDir, "test-workflow.yaml");
await writeFile(yamlPath, await createWorkflowYaml("test-workflow"));
const result = await cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot);
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
const uwf = await makeUwfStore(storageRoot);
const node = uwf.store.get(result.workflow);
expect(node).not.toBeNull();
if (node !== null) {
expect((node.payload as WorkflowPayload).name).toBe("test-workflow");
}
});
test("should load workflow from relative file path", async () => {
await makeUwfStore(storageRoot);
const yamlPath = "test-workflow.yaml";
await writeFile(join(projectRoot, yamlPath), await createWorkflowYaml("test-workflow"));
const result = await cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot);
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("should fail when file path does not exist", async () => {
await makeUwfStore(storageRoot);
await expect(
cmdThreadStart(storageRoot, "./nonexistent.yaml", "prompt", projectRoot),
).rejects.toThrow();
});
test("should fail on invalid YAML syntax in file", async () => {
await makeUwfStore(storageRoot);
const yamlPath = join(tmpDir, "bad-syntax.yaml");
await writeFile(yamlPath, "invalid: yaml: : :");
await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow();
});
test("should fail on valid YAML with invalid WorkflowPayload shape", async () => {
await makeUwfStore(storageRoot);
const yamlPath = join(tmpDir, "invalid-workflow.yaml");
await writeFile(yamlPath, "name: test\n# missing roles, conditions, and graph");
await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow();
});
test("should enforce filename matches workflow name", async () => {
await makeUwfStore(storageRoot);
const yamlPath = join(tmpDir, "solve-issue.yaml");
await writeFile(yamlPath, await createWorkflowYaml("wrong-name"));
await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow();
});
});
// ── Strategy 3: Local Discovery (Parent Traversal) ────────────────────────────
describe("Strategy 3: Local Discovery", () => {
test("should find workflow in current directory .workflow/", async () => {
await makeUwfStore(storageRoot);
const workflowDir = join(projectRoot, ".workflow");
await mkdir(workflowDir, { recursive: true });
await writeFile(join(workflowDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
const uwf = await makeUwfStore(storageRoot);
const node = uwf.store.get(result.workflow);
expect(node).not.toBeNull();
if (node !== null) {
expect((node.payload as WorkflowPayload).name).toBe("solve-issue");
}
});
test("should find workflow in parent directory .workflow/", async () => {
await makeUwfStore(storageRoot);
const workflowDir = join(projectRoot, ".workflow");
await mkdir(workflowDir, { recursive: true });
await writeFile(join(workflowDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue"));
const subdir = join(projectRoot, "packages", "cli-workflow", "src");
await mkdir(subdir, { recursive: true });
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", subdir);
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("should stop at filesystem root when traversing", async () => {
await makeUwfStore(storageRoot);
const deepPath = join(tmpDir, "deep", "path", "that", "does", "not", "have", "workflow");
await mkdir(deepPath, { recursive: true });
await expect(cmdThreadStart(storageRoot, "nonexistent", "prompt", deepPath)).rejects.toThrow();
});
test("should prefer .workflow/ over .workflows/ directory", async () => {
await makeUwfStore(storageRoot);
const workflowDir = join(projectRoot, ".workflow");
const workflowsDir = join(projectRoot, ".workflows");
await mkdir(workflowDir, { recursive: true });
await mkdir(workflowsDir, { recursive: true });
await writeFile(
join(workflowDir, "solve-issue.yaml"),
await createWorkflowYaml("solve-issue", "1"),
);
await writeFile(
join(workflowsDir, "solve-issue.yaml"),
await createWorkflowYaml("solve-issue", "2"),
);
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
const uwf = await makeUwfStore(storageRoot);
const node = uwf.store.get(result.workflow);
expect(node).not.toBeNull();
if (node !== null) {
expect((node.payload as WorkflowPayload).description).toBe("Test workflow (1)");
}
});
test("should support .yml extension in local discovery", async () => {
await makeUwfStore(storageRoot);
const workflowDir = join(projectRoot, ".workflow");
await mkdir(workflowDir, { recursive: true });
await writeFile(join(workflowDir, "solve-issue.yml"), await createWorkflowYaml("solve-issue"));
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
});
// ── Strategy 4: Global Registry Fallback ──────────────────────────────────────
describe("Strategy 4: Global Registry Resolution", () => {
test("should resolve workflow from global registry when not found locally", async () => {
const uwf = await makeUwfStore(storageRoot);
const hash = await storeWorkflow(uwf, "deploy-pipeline");
const registry = await loadWorkflowRegistry(storageRoot);
registry["deploy-pipeline"] = hash;
await saveWorkflowRegistry(storageRoot, registry);
const isolatedRoot = join(tmpDir, "isolated");
await mkdir(isolatedRoot, { recursive: true });
const result = await cmdThreadStart(storageRoot, "deploy-pipeline", "prompt", isolatedRoot);
expect(result.workflow).toBe(hash);
});
test("should fail when workflow not found in any strategy", async () => {
await makeUwfStore(storageRoot);
await expect(cmdThreadStart(storageRoot, "nonexistent", "prompt", tmpDir)).rejects.toThrow();
});
});
// ── Strategy Priority Order ───────────────────────────────────────────────────
describe("Resolution Priority", () => {
test("should use explicit file path over local discovery", async () => {
await makeUwfStore(storageRoot);
// Setup: Create workflow in .workflow/ AND as explicit file
const workflowDir = join(projectRoot, ".workflow");
await mkdir(workflowDir, { recursive: true });
await writeFile(
join(workflowDir, "solve-issue.yaml"),
await createWorkflowYaml("solve-issue", "discovery"),
);
const explicitPath = join(projectRoot, "custom-solve-issue.yaml");
await writeFile(explicitPath, await createWorkflowYaml("custom-solve-issue", "explicit"));
// Execute with explicit path
const result = await cmdThreadStart(storageRoot, explicitPath, "prompt", projectRoot);
const uwf = await makeUwfStore(storageRoot);
const node = uwf.store.get(result.workflow);
expect(node).not.toBeNull();
if (node !== null) {
expect((node.payload as WorkflowPayload).description).toBe("Test workflow (explicit)");
}
});
test("should use local discovery over global registry", async () => {
const uwf = await makeUwfStore(storageRoot);
// Setup: Register globally
const globalHash = await storeWorkflow(uwf, "solve-issue");
const registry = await loadWorkflowRegistry(storageRoot);
registry["solve-issue"] = globalHash;
await saveWorkflowRegistry(storageRoot, registry);
// Setup: Create local .workflow/
const workflowDir = join(projectRoot, ".workflow");
await mkdir(workflowDir, { recursive: true });
const localYaml = await createWorkflowYaml("solve-issue", "local");
await writeFile(join(workflowDir, "solve-issue.yaml"), localYaml);
const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot);
const uwf2 = await makeUwfStore(storageRoot);
const node = uwf2.store.get(result.workflow);
expect(node).not.toBeNull();
if (node !== null) {
expect((node.payload as WorkflowPayload).description).toBe("Test workflow (local)");
}
});
});
// ── Edge Cases ────────────────────────────────────────────────────────────────
describe("Edge Cases", () => {
test("should treat '13-char-string.yaml' as file path, not CAS hash", async () => {
await makeUwfStore(storageRoot);
const fileName = "0123456789ABC.yaml"; // 13 chars + .yaml
await writeFile(join(projectRoot, fileName), await createWorkflowYaml("0123456789ABC"));
const result = await cmdThreadStart(storageRoot, fileName, "prompt", projectRoot);
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("should handle workflow names containing slashes as file paths", async () => {
await makeUwfStore(storageRoot);
const filePath = "subdir/solve-issue.yaml";
const fullPath = join(projectRoot, filePath);
await mkdir(join(projectRoot, "subdir"), { recursive: true });
await writeFile(fullPath, await createWorkflowYaml("solve-issue"));
const result = await cmdThreadStart(storageRoot, filePath, "prompt", projectRoot);
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("should handle absolute paths correctly", async () => {
await makeUwfStore(storageRoot);
const absPath = join(tmpDir, "abs-workflow.yaml");
await writeFile(absPath, await createWorkflowYaml("abs-workflow"));
const result = await cmdThreadStart(storageRoot, absPath, "prompt", projectRoot);
expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("should fail on empty workflow ID", async () => {
await makeUwfStore(storageRoot);
await expect(cmdThreadStart(storageRoot, "", "prompt", projectRoot)).rejects.toThrow();
});
test("should fail on whitespace-only workflow ID", async () => {
await makeUwfStore(storageRoot);
await expect(cmdThreadStart(storageRoot, " ", "prompt", projectRoot)).rejects.toThrow();
});
});
@@ -137,75 +137,6 @@ function apiKeyEnvName(providerName: string): string {
return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
}
/**
* Discover uwf-* agent binaries in PATH.
* Returns sorted list of binary names (e.g., ["uwf-hermes", "uwf-claude-code"]).
*/
async function _discoverAgents(): Promise<string[]> {
try {
// Use which -a to find all uwf-* binaries in PATH
const proc = Bun.spawn(["which", "-a", "uwf-hermes", "uwf-claude-code", "uwf-cursor"], {
stdout: "pipe",
stderr: "pipe",
});
const text = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) {
// Try alternative approach: search PATH directories manually
const pathEnv = process.env.PATH || "";
const pathDirs = pathEnv.split(":").filter((d) => d.length > 0);
const agents = new Set<string>();
for (const dir of pathDirs) {
try {
if (!existsSync(dir)) continue;
const { readdirSync, statSync } = await import("node:fs");
const entries = readdirSync(dir);
for (const entry of entries) {
if (!entry.startsWith("uwf-") || entry === "uwf") continue;
const fullPath = join(dir, entry);
try {
const stat = statSync(fullPath);
// Check if executable (owner, group, or other has execute bit)
if (stat.isFile() && (stat.mode & 0o111) !== 0) {
agents.add(entry);
}
} catch {
// Skip if can't stat
}
}
} catch {
// Skip inaccessible directories
}
}
return Array.from(agents).sort();
}
// Parse which output - each line is a path to a binary
const paths = text
.trim()
.split("\n")
.filter((line) => line.length > 0);
const agents = new Set<string>();
for (const path of paths) {
const basename = path.split("/").pop();
if (basename?.startsWith("uwf-") && basename !== "uwf") {
agents.add(basename);
}
}
return Array.from(agents).sort();
} catch {
// If all fails, return empty array
return [];
}
}
/**
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
*/
+17 -118
View File
@@ -1,6 +1,5 @@
import { execFileSync } from "node:child_process";
import { access, readFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
import { readFile } from "node:fs/promises";
import type { Store as CasStore, JSONSchema } from "@uncaged/json-cas";
import { getSchema, validate } from "@uncaged/json-cas";
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-agent-kit";
@@ -31,10 +30,12 @@ import { parse, stringify } from "yaml";
import {
appendThreadHistory,
createUwfStore,
discoverProjectWorkflows,
findThreadInHistory,
loadThreadHistory,
loadThreadsIndex,
loadWorkflowRegistry,
resolveProjectWorkflowFile,
resolveWorkflowHash,
saveThreadsIndex,
type ThreadHistoryLine,
@@ -81,83 +82,6 @@ function fail(message: string): never {
process.exit(1);
}
/**
* Check if a string looks like a file path (contains path separators or has .yaml/.yml extension).
*/
function isFilePath(input: string): boolean {
return (
input.includes("/") || input.includes("\\") || input.endsWith(".yaml") || input.endsWith(".yml")
);
}
/**
* Check if a workflow file exists at the given path.
*/
async function workflowFileExists(dir: string, name: string, ext: string): Promise<string | null> {
const candidate = resolvePath(dir, `${name}${ext}`);
try {
await access(candidate);
return candidate;
} catch {
return null;
}
}
/**
* Search for a workflow file in a given directory (checks both .workflow/ and .workflows/).
*/
async function findWorkflowInDir(dir: string, name: string): Promise<string | null> {
// Check .workflow/ directory first (preferred)
for (const ext of [".yaml", ".yml"]) {
const result = await workflowFileExists(resolvePath(dir, ".workflow"), name, ext);
if (result !== null) {
return result;
}
}
// Check .workflows/ directory as fallback (legacy)
for (const ext of [".yaml", ".yml"]) {
const result = await workflowFileExists(resolvePath(dir, ".workflows"), name, ext);
if (result !== null) {
return result;
}
}
return null;
}
/**
* Traverse parent directories looking for `.workflow/<name>.yaml` or `.workflow/<name>.yml`.
* Returns the absolute path if found, otherwise null.
* Stops at filesystem root or .git directory.
*/
async function findWorkflowInParents(startDir: string, name: string): Promise<string | null> {
let currentDir = resolvePath(startDir);
const root = resolvePath("/");
while (true) {
const found = await findWorkflowInDir(currentDir, name);
if (found !== null) {
return found;
}
// Stop at filesystem root
if (currentDir === root) {
break;
}
// Move to parent directory
const parentDir = dirname(currentDir);
if (parentDir === currentDir) {
// Reached filesystem root
break;
}
currentDir = parentDir;
}
return null;
}
async function materializeLocalWorkflow(uwf: UwfStore, filePath: string): Promise<CasRef> {
let text: string;
try {
@@ -199,41 +123,18 @@ async function resolveWorkflowCasRef(
workflowId: string,
projectRoot: string,
): Promise<CasRef> {
// Validate input
const trimmed = workflowId.trim();
if (trimmed === "") {
fail("workflow ID cannot be empty");
// Project-local resolution: check .workflows/<workflowId>.yaml first
const localEntries = await discoverProjectWorkflows(projectRoot);
const localFile = resolveProjectWorkflowFile(localEntries, workflowId);
if (localFile !== null) {
return materializeLocalWorkflow(uwf, localFile);
}
// Strategy 1: Direct CAS hash
if (isCasRef(trimmed)) {
const node = uwf.store.get(trimmed);
if (node === null) {
fail(`CAS node not found: ${trimmed}`);
}
if (node.type !== uwf.schemas.workflow) {
fail(`node ${trimmed} is not a Workflow (type ${node.type})`);
}
return trimmed;
}
// Strategy 2: Explicit file path (relative or absolute)
if (isFilePath(trimmed)) {
const absolutePath = isAbsolute(trimmed) ? trimmed : resolvePath(projectRoot, trimmed);
return materializeLocalWorkflow(uwf, absolutePath);
}
// Strategy 3: Local discovery (parent directory traversal)
const localPath = await findWorkflowInParents(projectRoot, trimmed);
if (localPath !== null) {
return materializeLocalWorkflow(uwf, localPath);
}
// Strategy 4: Global registry fallback
// Global registry fallback
const registry = await loadWorkflowRegistry(storageRoot);
const hash = resolveWorkflowHash(registry, trimmed);
const hash = resolveWorkflowHash(registry, workflowId);
if (!isCasRef(hash)) {
fail(`workflow not found: ${trimmed}`);
fail(`workflow not found: ${workflowId}`);
}
const node = uwf.store.get(hash);
if (node === null) {
@@ -539,7 +440,7 @@ function collectOrderedSteps(
}
function formatYaml(value: unknown): string {
return stringify(value, { aliasDuplicateObjects: false }).trimEnd();
return stringify(value).trimEnd();
}
function formatCompactStep(index: number, item: OrderedStepItem, outputYaml: string): string {
@@ -655,11 +556,11 @@ function formatThreadReadMarkdown(options: {
// Step blocks
const startIndex = candidates.length - selected.length;
const shownPromptRoles = new Set<string>();
for (let i = 0; i < selected.length; i++) {
const item = selected[i];
if (item === undefined) continue;
const stepNum = startIndex + i + 1;
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
const ts = new Date(item.timestamp)
.toISOString()
.replace("T", " ")
@@ -669,10 +570,9 @@ function formatThreadReadMarkdown(options: {
`**Agent:** ${item.payload.agent} | **Time:** ${ts}`,
];
const roleDef = workflow.roles[item.payload.role];
if (roleDef && !shownPromptRoles.has(item.payload.role)) {
if (roleDef) {
const prompt = roleDef.goal;
stepLines.push("", "### Prompt", "", prompt);
shownPromptRoles.add(item.payload.role);
}
if (item.payload.detail) {
const content = extractLastAssistantContent(uwf, item.payload.detail);
@@ -680,6 +580,7 @@ function formatThreadReadMarkdown(options: {
stepLines.push("", "### Content", "", content);
}
}
stepLines.push("", "### Output", "", "```yaml", outputYaml, "```");
parts.push(stepLines.join("\n"));
}
@@ -693,7 +594,6 @@ function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorConte
output: expandOutput(uwf, step.output),
detail: step.detail,
agent: step.agent,
edgePrompt: step.edgePrompt ?? "",
}));
return { start: chain.start, steps };
}
@@ -761,12 +661,11 @@ function spawnAgent(
encoding: "utf8",
env,
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 50 * 1024 * 1024, // 50 MB — stream-json output can be large
});
} catch (e) {
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string | null };
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string };
const stderr =
err.stderr == null
err.stderr === undefined
? ""
: typeof err.stderr === "string"
? err.stderr
+1 -1
View File
@@ -7,6 +7,6 @@ export function formatOutput(data: unknown, format: OutputFormat): string {
case "json":
return JSON.stringify(data);
case "yaml":
return stringify(data, { aliasDuplicateObjects: false }).trimEnd();
return stringify(data).trimEnd();
}
}
-141
View File
@@ -1,141 +0,0 @@
# @uncaged/workflow-agent-builtin
`uwf-builtin` agent — built-in LLM agent with file read/write and shell tools.
## Overview
Layer 3 agent implementation. Runs an OpenAI-compatible chat completion loop with built-in tools (`read_file`, `write_file`, `run_command`). Uses the configured provider/model from `config.yaml`. Produces frontmatter markdown output and stores turn-by-turn session detail in CAS.
Useful when you want a self-contained agent without an external CLI like Hermes or Claude Code.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-agent-kit`, `@uncaged/workflow-util`
## Installation
Included as the `uwf-builtin` binary when you install `@uncaged/workflow-agent-builtin`:
```bash
bun add -g @uncaged/workflow-agent-builtin
```
## CLI Usage
Invoked by `uwf thread step`:
```bash
uwf-builtin <thread-id> <role>
```
Configure as default agent:
```bash
uwf setup --agent builtin
```
Override per step:
```bash
uwf thread step <thread-id> --agent uwf-builtin
```
Environment variables set by the engine:
| Variable | Purpose |
|----------|---------|
| `UWF_EDGE_PROMPT` | Moderator edge instruction for this step |
## API
All exports come from `src/index.ts`.
### Agent factory
```typescript
function createBuiltinAgent(): () => Promise<void>
function buildBuiltinMessages(ctx: AgentContext): ChatMessage[]
```
### LLM loop
```typescript
const BUILTIN_MAX_TURNS = 30;
const BUILTIN_CONTINUE_MAX_TURNS = 5;
function runBuiltinLoop(/* options: RunBuiltinLoopOptions */): Promise<RunBuiltinLoopResult>
function chatCompletionWithTools(
provider: ResolvedLlmProvider,
messages: ChatMessage[],
tools: OpenAiToolDefinition[],
): Promise<LlmAssistantResponse>
```
`RunBuiltinLoopOptions` and `RunBuiltinLoopResult` are internal to `loop.ts` and not re-exported from `index.ts`.
### Tools
```typescript
function getBuiltinTools(): readonly BuiltinTool[]
function executeBuiltinTool(
name: string,
args: Record<string, unknown>,
ctx: ToolContext,
): Promise<string>
```
### Session and detail
```typescript
function initSessionDir(storageRoot: string): Promise<void>
function appendSessionTurn(storageRoot: string, sessionId: string, turn: BuiltinTurnPayload): Promise<void>
function readSessionTurns(storageRoot: string, sessionId: string): Promise<BuiltinTurnPayload[]>
function removeSession(storageRoot: string, sessionId: string): Promise<void>
function registerBuiltinSchemas(store: Store): Promise<BuiltinSchemaHashes>
function storeBuiltinDetail(store: Store, payload: BuiltinDetailPayload): Promise<string>
```
### Types
```typescript
type ChatMessage = /* system | user | assistant | tool */;
type LlmAssistantResponse = { content: string | null; toolCalls: LlmToolCall[] | null };
type LlmToolCall = { id: string; name: string; arguments: string };
type BuiltinTool = { name: string; description: string; parameters: Record<string, unknown> };
type ToolContext = { cwd: string; storageRoot: string };
type BuiltinDetailPayload = { /* session turns, model, timestamps */ };
type BuiltinLoopTurn = { /* single loop iteration record */ };
type BuiltinToolCallRecord = { /* tool call audit */ };
type BuiltinToolResultRecord = { /* tool result audit */ };
type BuiltinTurnPayload = { /* persisted turn */ };
```
## Internal Structure
```
src/
├── index.ts
├── cli.ts Binary entrypoint
├── agent.ts createBuiltinAgent
├── loop.ts Multi-turn LLM + tool loop
├── prompt.ts buildBuiltinMessages
├── session.ts Session directory persistence
├── detail.ts CAS detail node storage
├── schemas.ts Builtin CAS schemas
├── types.ts Detail and turn payload types
├── llm/
│ ├── index.ts
│ ├── llm.ts chatCompletionWithTools
│ └── types.ts ChatMessage, LlmToolCall, etc.
└── tools/
├── index.ts getBuiltinTools, executeBuiltinTool
├── read-file.ts
├── write-file.ts
├── run-command.ts
├── path.ts
└── types.ts
```
## Configuration
Requires a configured OpenAI-compatible provider and model in `~/.uncaged/workflow/config.yaml` (via `uwf setup`). API keys are loaded from `~/.uncaged/workflow/.env`.
Tools run with the current working directory as `ToolContext.cwd` (typically the directory where `uwf thread step` was invoked).
@@ -1,16 +0,0 @@
import { describe, expect, test } from "bun:test";
import type { LlmToolCall } from "../src/llm/types.js";
/** Mirror OpenAI response shape for parser coverage via chatCompletionWithTools integration later. */
describe("LlmToolCall shape", () => {
test("tool call record fields", () => {
const call: LlmToolCall = {
id: "call_1",
name: "read_file",
arguments: '{"path":"README.md"}',
};
expect(call.name).toBe("read_file");
expect(JSON.parse(call.arguments)).toEqual({ path: "README.md" });
});
});
@@ -1,21 +0,0 @@
import { describe, expect, test } from "bun:test";
import { resolve } from "node:path";
import { resolvePath } from "../src/tools/path.js";
describe("resolvePath", () => {
test("resolves relative paths against cwd", () => {
const root = "/workspace/project";
const resolved = resolvePath(root, "src/foo.ts");
expect(resolved).toBe(resolve(root, "src/foo.ts"));
});
test("resolves absolute paths as-is", () => {
const resolved = resolvePath("/workspace", "/etc/hosts");
expect(resolved).toBe("/etc/hosts");
});
test("resolves parent traversal normally", () => {
const resolved = resolvePath("/workspace/project", "../other/file.ts");
expect(resolved).toBe(resolve("/workspace/project", "../other/file.ts"));
});
});
@@ -1,236 +0,0 @@
import { describe, expect, test } from "bun:test";
import type { AgentContext } from "@uncaged/workflow-agent-kit";
import { buildBuiltinMessages } from "../src/prompt.js";
function minimalContext(overrides: Partial<AgentContext> = {}): AgentContext {
return {
threadId: "00000000000000000000000000" as AgentContext["threadId"],
role: "developer",
store: {} as AgentContext["store"],
workflow: {
name: "test",
description: "test workflow",
roles: {
developer: {
description: "Developer role",
goal: "Ship the fix",
capabilities: ["file-edit"],
procedure: "Edit files",
output: "A patch",
frontmatter: "schema-hash",
},
},
conditions: {},
graph: {},
},
start: { workflow: "wf-hash", prompt: "Fix the bug" },
steps: [],
outputFormatInstruction: "---\nstatus: done\n---",
edgePrompt: "Implement the fix described in the plan.",
isFirstVisit: true,
...overrides,
};
}
describe("buildBuiltinMessages", () => {
test("system includes output format and role goal", () => {
const messages = buildBuiltinMessages(minimalContext());
const system = messages[0];
expect(system?.role).toBe("system");
if (system?.role === "system") {
expect(system.content).toContain("status: done");
expect(system.content).toContain("## Goal");
expect(system.content).toContain("Ship the fix");
}
});
test("first visit produces system + single user message with edge prompt", () => {
const messages = buildBuiltinMessages(minimalContext());
expect(messages).toHaveLength(2);
expect(messages[1]?.role).toBe("user");
if (messages[1]?.role === "user") {
expect(messages[1].content).toContain("Implement the fix");
expect(messages[1].content).not.toContain("## What Happened Since Your Last Turn");
}
});
test("first visit with prior steps includes inter-step summary in final user message", () => {
const messages = buildBuiltinMessages(
minimalContext({
steps: [
{
role: "planner",
output: { plan: "step 1" },
agent: "uwf-builtin",
detail: "detail-hash",
edgePrompt: "Create a plan.",
},
],
}),
);
expect(messages).toHaveLength(2);
const finalUser = messages[1];
if (finalUser?.role === "user") {
expect(finalUser.content).toContain("Implement the fix");
expect(finalUser.content).toContain("## What Happened Since Your Last Turn");
expect(finalUser.content).toContain("planner");
}
});
test("re-entry reconstructs prior user/assistant turns plus current user message", () => {
const messages = buildBuiltinMessages(
minimalContext({
isFirstVisit: false,
edgePrompt: "Fix the reviewer's feedback.",
steps: [
{
role: "developer",
output: { summary: "Initial fix" },
agent: "uwf-builtin",
detail: "detail-1",
edgePrompt: "Implement the fix.",
},
{
role: "reviewer",
output: { approved: false, comments: "Missing tests" },
agent: "uwf-builtin",
detail: "detail-2",
edgePrompt: "Review the implementation.",
},
],
}),
);
expect(messages).toHaveLength(4);
expect(messages[0]?.role).toBe("system");
expect(messages[1]?.role).toBe("user");
expect(messages[2]?.role).toBe("assistant");
expect(messages[3]?.role).toBe("user");
if (messages[1]?.role === "user") {
expect(messages[1].content).toBe("Implement the fix.");
}
if (messages[2]?.role === "assistant") {
expect(messages[2].content).toBe(JSON.stringify({ summary: "Initial fix" }));
}
if (messages[3]?.role === "user") {
expect(messages[3].content).toContain("Fix the reviewer's feedback.");
expect(messages[3].content).toContain("## What Happened Since Your Last Turn");
expect(messages[3].content).toContain("reviewer");
expect(messages[3].content).toContain("Missing tests");
}
});
test("prefix is stable across re-entry for LLM cache hits", () => {
const firstVisitMessages = buildBuiltinMessages(
minimalContext({
edgePrompt: "Implement the fix.",
steps: [],
}),
);
const reEntryMessages = buildBuiltinMessages(
minimalContext({
isFirstVisit: false,
edgePrompt: "Fix the reviewer's feedback.",
steps: [
{
role: "developer",
output: { summary: "Initial fix" },
agent: "uwf-builtin",
detail: "detail-1",
edgePrompt: "Implement the fix.",
},
{
role: "reviewer",
output: { approved: false },
agent: "uwf-builtin",
detail: "detail-2",
edgePrompt: "Review the code.",
},
],
}),
);
expect(reEntryMessages[0]).toEqual(firstVisitMessages[0]);
expect(reEntryMessages[1]).toEqual(firstVisitMessages[1]);
expect(reEntryMessages[2]?.role).toBe("assistant");
if (reEntryMessages[2]?.role === "assistant") {
expect(reEntryMessages[2].content).toBe(JSON.stringify({ summary: "Initial fix" }));
}
expect(reEntryMessages[3]?.role).toBe("user");
if (reEntryMessages[3]?.role === "user") {
expect(reEntryMessages[3].content).toContain("Fix the reviewer's feedback.");
}
});
test("multiple prior visits emit one user/assistant pair per visit", () => {
const messages = buildBuiltinMessages(
minimalContext({
isFirstVisit: false,
edgePrompt: "Third round fix.",
steps: [
{
role: "developer",
output: { round: 1 },
agent: "uwf-builtin",
detail: "d1",
edgePrompt: "First attempt.",
},
{
role: "reviewer",
output: { approved: false },
agent: "uwf-builtin",
detail: "d2",
edgePrompt: "Review round 1.",
},
{
role: "developer",
output: { round: 2 },
agent: "uwf-builtin",
detail: "d3",
edgePrompt: "Second attempt.",
},
{
role: "reviewer",
output: { approved: false },
agent: "uwf-builtin",
detail: "d4",
edgePrompt: "Review round 2.",
},
],
}),
);
expect(messages).toHaveLength(6);
expect(messages.map((m) => m.role)).toEqual([
"system",
"user",
"assistant",
"user",
"assistant",
"user",
]);
if (messages[1]?.role === "user") {
expect(messages[1].content).toBe("First attempt.");
}
if (messages[2]?.role === "assistant") {
expect(messages[2].content).toBe(JSON.stringify({ round: 1 }));
}
if (messages[3]?.role === "user") {
expect(messages[3].content).toContain("Second attempt.");
expect(messages[3].content).toContain("reviewer");
}
if (messages[4]?.role === "assistant") {
expect(messages[4].content).toBe(JSON.stringify({ round: 2 }));
}
if (messages[5]?.role === "user") {
expect(messages[5].content).toContain("Third round fix.");
expect(messages[5].content).toContain("### Step 4: reviewer");
expect(messages[5].content).toContain('"approved":false');
}
});
});
@@ -1,34 +0,0 @@
{
"name": "@uncaged/workflow-agent-builtin",
"version": "0.5.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"bin": {
"uwf-builtin": "./src/cli.ts"
},
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "^0.4.0",
"@uncaged/workflow-agent-kit": "workspace:^",
"@uncaged/workflow-util": "workspace:^"
},
"devDependencies": {
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
}
}
@@ -1,158 +0,0 @@
import type { Store } from "@uncaged/json-cas";
import {
type AgentContext,
type AgentRunResult,
createAgent,
loadWorkflowConfig,
resolveModel,
resolveStorageRoot,
} from "@uncaged/workflow-agent-kit";
import { createLogger, generateUlid } from "@uncaged/workflow-util";
import { storeBuiltinDetail } from "./detail.js";
import type { ChatMessage } from "./llm/index.js";
import { BUILTIN_CONTINUE_MAX_TURNS, BUILTIN_MAX_TURNS, runBuiltinLoop } from "./loop.js";
import { buildBuiltinMessages } from "./prompt.js";
import { initSessionDir } from "./session.js";
const log = createLogger({ sink: { kind: "stderr" } });
const FRONTMATTER_FENCE = "---";
/**
* Strip any text before the first `---` fence.
* LLMs sometimes emit preamble text before the frontmatter block.
*/
function stripPreamble(text: string): string {
if (text.startsWith(FRONTMATTER_FENCE)) {
return text;
}
const idx = text.indexOf(`\n${FRONTMATTER_FENCE}\n`);
if (idx !== -1) {
log("6GWRP3QX", `stripped ${idx + 1} chars of preamble before frontmatter`);
return text.slice(idx + 1);
}
return text;
}
type SessionRecord = {
sessionId: string;
model: string;
startedAtMs: number;
messages: ChatMessage[];
};
const sessions = new Map<string, SessionRecord>();
function getSession(sessionId: string): SessionRecord {
const session = sessions.get(sessionId);
if (session === undefined) {
throw new Error(`builtin session not found: ${sessionId}`);
}
return session;
}
function buildToolContext(storageRoot: string): { cwd: string; storageRoot: string } {
return {
cwd: process.cwd(),
storageRoot,
};
}
async function runBuiltinWithMessages(
storageRoot: string,
provider: ReturnType<typeof resolveModel>,
messages: ChatMessage[],
session: SessionRecord,
store: Store,
maxTurns: number,
noTools: boolean,
): Promise<AgentRunResult> {
const loopResult = await runBuiltinLoop({
provider,
messages,
toolCtx: buildToolContext(storageRoot),
maxTurns,
storageRoot,
sessionId: session.sessionId,
noTools,
});
session.messages = loopResult.messages;
if (loopResult.turnCount === 0) {
log("5RWTK9NB", "no turns produced, returning empty output");
return { output: "", detailHash: "", sessionId: session.sessionId };
}
// Read jsonl → persist turns to CAS → store detail
const { detailHash } = await storeBuiltinDetail(
store,
storageRoot,
session.sessionId,
session.model,
session.startedAtMs,
);
return { output: stripPreamble(loopResult.finalText), detailHash, sessionId: session.sessionId };
}
async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
const storageRoot = resolveStorageRoot();
const config = await loadWorkflowConfig(storageRoot);
const provider = resolveModel(config, config.defaultModel);
const sessionId = generateUlid(Date.now());
await initSessionDir(storageRoot);
const messages = buildBuiltinMessages(ctx);
const session: SessionRecord = {
sessionId,
model: provider.model,
startedAtMs: Date.now(),
messages,
};
sessions.set(sessionId, session);
return runBuiltinWithMessages(
storageRoot,
provider,
messages,
session,
ctx.store,
BUILTIN_MAX_TURNS,
false,
);
}
async function continueBuiltin(
sessionId: string,
message: string,
store: Store,
): Promise<AgentRunResult> {
const session = getSession(sessionId);
const storageRoot = resolveStorageRoot();
const config = await loadWorkflowConfig(storageRoot);
const provider = resolveModel(config, config.defaultModel);
const messages: ChatMessage[] = [...session.messages, { role: "user", content: message }];
return runBuiltinWithMessages(
storageRoot,
provider,
messages,
session,
store,
BUILTIN_CONTINUE_MAX_TURNS,
true,
);
}
/** Agent CLI factory: built-in LLM loop with file/shell tools. */
export function createBuiltinAgent(): () => Promise<void> {
return createAgent({
name: "builtin",
run: runBuiltin,
continue: continueBuiltin,
});
}
@@ -1,6 +0,0 @@
#!/usr/bin/env bun
import { createBuiltinAgent } from "./agent.js";
const main = createBuiltinAgent();
void main();
@@ -1,49 +0,0 @@
import { bootstrap, putSchema, type Store } from "@uncaged/json-cas";
import { BUILTIN_DETAIL_SCHEMA, BUILTIN_TURN_SCHEMA } from "./schemas.js";
import { readSessionTurns } from "./session.js";
import type { BuiltinDetailPayload } from "./types.js";
type BuiltinSchemaHashes = {
turn: string;
detail: string;
};
export async function registerBuiltinSchemas(store: Store): Promise<BuiltinSchemaHashes> {
await bootstrap(store);
const [turn, detail] = await Promise.all([
putSchema(store, BUILTIN_TURN_SCHEMA),
putSchema(store, BUILTIN_DETAIL_SCHEMA),
]);
return { turn, detail };
}
/** Read session jsonl, persist each turn to CAS, return detail hash. */
export async function storeBuiltinDetail(
store: Store,
storageRoot: string,
sessionId: string,
model: string,
startedAtMs: number,
nowMs: number = Date.now(),
): Promise<{ detailHash: string; turnCount: number }> {
const schemas = await registerBuiltinSchemas(store);
const turns = await readSessionTurns(storageRoot, sessionId);
const turnHashes: string[] = [];
for (const turn of turns) {
const hash = await store.put(schemas.turn, turn);
turnHashes.push(hash);
}
const duration = Math.max(0, nowMs - startedAtMs);
const detail: BuiltinDetailPayload = {
sessionId,
model,
duration,
turnCount: turnHashes.length,
turns: turnHashes,
};
const detailHash = await store.put(schemas.detail, detail);
return { detailHash, turnCount: turnHashes.length };
}
@@ -1,16 +0,0 @@
export { createBuiltinAgent } from "./agent.js";
export { registerBuiltinSchemas, storeBuiltinDetail } from "./detail.js";
export type { ChatMessage, LlmAssistantResponse, LlmToolCall } from "./llm/index.js";
export { chatCompletionWithTools } from "./llm/index.js";
export { BUILTIN_CONTINUE_MAX_TURNS, BUILTIN_MAX_TURNS, runBuiltinLoop } from "./loop.js";
export { buildBuiltinMessages } from "./prompt.js";
export { appendSessionTurn, initSessionDir, readSessionTurns, removeSession } from "./session.js";
export type { BuiltinTool, ToolContext } from "./tools/index.js";
export { executeBuiltinTool, getBuiltinTools } from "./tools/index.js";
export type {
BuiltinDetailPayload,
BuiltinLoopTurn,
BuiltinToolCallRecord,
BuiltinToolResultRecord,
BuiltinTurnPayload,
} from "./types.js";
@@ -1,7 +0,0 @@
export { chatCompletionWithTools } from "./llm.js";
export type {
ChatMessage,
LlmAssistantResponse,
LlmToolCall,
OpenAiToolDefinition,
} from "./types.js";
@@ -1,139 +0,0 @@
import type { ResolvedLlmProvider } from "@uncaged/workflow-agent-kit";
import type {
ChatMessage,
LlmAssistantResponse,
LlmToolCall,
OpenAiToolDefinition,
} from "./types.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function chatUrl(baseUrl: string): string {
const trimmed = baseUrl.replace(/\/+$/, "");
return `${trimmed}/chat/completions`;
}
function parseToolCalls(raw: unknown): LlmToolCall[] | null {
if (!Array.isArray(raw) || raw.length === 0) {
return null;
}
const calls: LlmToolCall[] = [];
for (const entry of raw) {
if (!isRecord(entry)) {
continue;
}
const id = entry.id;
const fn = entry.function;
if (typeof id !== "string" || !isRecord(fn)) {
continue;
}
const name = fn.name;
const args = fn.arguments;
if (typeof name !== "string" || typeof args !== "string") {
continue;
}
calls.push({ id, name, arguments: args });
}
return calls.length > 0 ? calls : null;
}
function parseAssistantMessage(parsed: unknown): LlmAssistantResponse {
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 contentRaw = messageObj.content;
const content =
typeof contentRaw === "string"
? contentRaw
: contentRaw === null || contentRaw === undefined
? null
: null;
const toolCalls = parseToolCalls(messageObj.tool_calls);
return { content, toolCalls };
}
function serializeMessage(message: ChatMessage): Record<string, unknown> {
if (message.role === "tool") {
return {
role: "tool",
tool_call_id: message.tool_call_id,
content: message.content,
};
}
if (message.role === "assistant") {
const base: Record<string, unknown> = {
role: "assistant",
content: message.content,
};
if (message.tool_calls !== null && message.tool_calls.length > 0) {
base.tool_calls = message.tool_calls.map((call) => ({
id: call.id,
type: "function",
function: { name: call.name, arguments: call.arguments },
}));
}
return base;
}
return { role: message.role, content: message.content };
}
/** OpenAI-compatible chat completion with tool calling (non-streaming). */
export async function chatCompletionWithTools(
provider: ResolvedLlmProvider,
messages: ChatMessage[],
tools: OpenAiToolDefinition[] | null,
): Promise<LlmAssistantResponse> {
const body: Record<string, unknown> = {
model: provider.model,
messages: messages.map(serializeMessage),
};
if (tools !== null && tools.length > 0) {
body.tools = tools;
body.tool_choice = "auto";
}
let response: Response;
try {
response = await fetch(chatUrl(provider.baseUrl), {
method: "POST",
headers: {
Authorization: `Bearer ${provider.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
} 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 parseAssistantMessage(parsed);
}
@@ -1,29 +0,0 @@
export type LlmToolCall = {
id: string;
name: string;
arguments: string;
};
export type LlmAssistantResponse = {
content: string | null;
toolCalls: LlmToolCall[] | null;
};
export type ChatMessage =
| { role: "system"; content: string }
| { role: "user"; content: string }
| {
role: "assistant";
content: string | null;
tool_calls: LlmToolCall[] | null;
}
| { role: "tool"; tool_call_id: string; content: string };
export type OpenAiToolDefinition = {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
};
-157
View File
@@ -1,157 +0,0 @@
import type { ResolvedLlmProvider } from "@uncaged/workflow-agent-kit";
import { createLogger } from "@uncaged/workflow-util";
import { type ChatMessage, chatCompletionWithTools, type LlmToolCall } from "./llm/index.js";
import { appendSessionTurn } from "./session.js";
import {
builtinToolsToOpenAi,
executeBuiltinTool,
getBuiltinTools,
type ToolContext,
} from "./tools/index.js";
import type { BuiltinToolCall, BuiltinTurnPayload } from "./types.js";
const log = createLogger({ sink: { kind: "stderr" } });
export const BUILTIN_MAX_TURNS = 30;
export const BUILTIN_CONTINUE_MAX_TURNS = 5;
export type RunBuiltinLoopOptions = {
provider: ResolvedLlmProvider;
messages: ChatMessage[];
toolCtx: ToolContext;
maxTurns: number;
storageRoot: string;
sessionId: string;
/** When true, do not provide tools — force LLM to emit text only. */
noTools: boolean;
};
export type RunBuiltinLoopResult = {
finalText: string;
messages: ChatMessage[];
turnCount: number;
};
function mapToolCallsForPayload(calls: LlmToolCall[]): BuiltinToolCall[] {
return calls.map((call) => ({
name: call.name,
args: call.arguments,
}));
}
async function appendTurn(
storageRoot: string,
sessionId: string,
payload: BuiltinTurnPayload,
): Promise<void> {
await appendSessionTurn(storageRoot, sessionId, payload);
}
async function executeTurnTools(
calls: Array<{ id: string; name: string; arguments: string }>,
toolCtx: ToolContext,
messages: ChatMessage[],
storageRoot: string,
sessionId: string,
): Promise<number> {
let turnCount = 0;
for (const call of calls) {
const result = await executeBuiltinTool(call.name, call.arguments, toolCtx);
messages.push({ role: "tool", tool_call_id: call.id, content: result });
await appendTurn(storageRoot, sessionId, {
role: "tool",
content: result,
toolCalls: null,
reasoning: null,
});
turnCount += 1;
}
return turnCount;
}
/** Agent run loop: LLM ↔ tools until no tool_calls or maxTurns. */
export async function runBuiltinLoop(
options: RunBuiltinLoopOptions,
): Promise<RunBuiltinLoopResult> {
const messages = [...options.messages];
const openAiTools = options.noTools ? [] : builtinToolsToOpenAi(getBuiltinTools());
let finalText = "";
let turnCount = 0;
for (let turn = 0; turn < options.maxTurns; turn++) {
log("8K2M4N7P", `builtin loop turn ${turn + 1}/${options.maxTurns}`);
const response = await chatCompletionWithTools(
options.provider,
messages,
openAiTools.length > 0 ? openAiTools : null,
);
const assistantMessage: ChatMessage = {
role: "assistant",
content: response.content,
tool_calls: response.toolCalls,
};
messages.push(assistantMessage);
if (response.toolCalls === null || response.toolCalls.length === 0) {
const text = response.content ?? "";
await appendTurn(options.storageRoot, options.sessionId, {
role: "assistant",
content: text,
toolCalls: null,
reasoning: null,
});
turnCount += 1;
// If tools are available but LLM stopped calling them without producing
// frontmatter, nudge it to continue working or output frontmatter.
if (!options.noTools && !text.trimStart().startsWith("---") && turn < options.maxTurns - 1) {
log("7FXQM2KN", "text-only turn without frontmatter, nudging LLM to continue");
const nudge =
"You stopped calling tools but your response does not start with the required `---` YAML frontmatter. " +
"Either continue using tools to complete your work, or output your final response starting with `---`.";
messages.push({ role: "user", content: nudge });
continue;
}
finalText = text;
break;
}
// Assistant turn with tool calls
await appendTurn(options.storageRoot, options.sessionId, {
role: "assistant",
content: response.content ?? "",
toolCalls: mapToolCallsForPayload(response.toolCalls),
reasoning: null,
});
turnCount += 1;
// Execute tools
turnCount += await executeTurnTools(
response.toolCalls,
options.toolCtx,
messages,
options.storageRoot,
options.sessionId,
);
}
if (finalText === "" && messages.length > 0) {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (
msg !== undefined &&
msg.role === "assistant" &&
msg.content !== null &&
msg.content.trim() !== ""
) {
finalText = msg.content;
break;
}
}
}
return { finalText, messages, turnCount };
}
@@ -1,114 +0,0 @@
import { type AgentContext, buildRolePrompt } from "@uncaged/workflow-agent-kit";
import type { ChatMessage } from "./llm/index.js";
type StepContext = AgentContext["steps"][number];
function formatStep(step: StepContext, stepNumber: number): string {
return [
`### Step ${stepNumber}: ${step.role}`,
`Output: ${JSON.stringify(step.output)}`,
`Agent: ${step.agent}`,
].join("\n");
}
function buildStepsSummary(steps: StepContext[], fromIndex: number, toIndex: number): string {
if (fromIndex >= toIndex) {
return "";
}
const lines: string[] = ["## What Happened Since Your Last Turn"];
for (let i = fromIndex; i < toIndex; i++) {
const step = steps[i];
if (step === undefined) {
continue;
}
lines.push("");
lines.push(formatStep(step, i + 1));
}
return lines.join("\n");
}
function buildUserTurnContent(edgePrompt: string, summary: string): string {
const parts: string[] = [];
if (edgePrompt !== "") {
parts.push(edgePrompt);
}
if (summary !== "") {
if (parts.length > 0) {
parts.push("");
}
parts.push(summary);
}
return parts.join("\n");
}
/**
* Reconstruct multi-turn chat messages from thread history for cache-friendly session resume.
*
* - system: role prompt + output format (stable prefix)
* - For each prior visit of this role: user (edgePrompt + inter-step summary) + assistant (output JSON)
* - Final user: current edgePrompt + summary since last visit of this role
*/
export function buildBuiltinMessages(ctx: AgentContext): ChatMessage[] {
const roleDef = ctx.workflow.roles[ctx.role];
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
const systemParts: string[] = [];
if (ctx.outputFormatInstruction !== "") {
systemParts.push(ctx.outputFormatInstruction, "");
}
systemParts.push(rolePrompt);
systemParts.push(
"",
"## Workflow",
"",
`Your working directory is: ${process.cwd()}`,
"",
"You have tools available (read_file, write_file, run_command). " +
"Use them to complete your task — read files, run commands, make changes as needed. " +
"Your task is described in the user message below — do NOT use uwf or workflow CLI commands to discover your task. " +
"When you are done, output your final response with the YAML frontmatter block as specified above. " +
"Do NOT output the frontmatter until you have completed all necessary work. " +
"CRITICAL: Your final output MUST start with the `---` fence on the very first line — " +
"no preamble text, no explanation before it. The parser requires `---` at position 0.",
);
const messages: ChatMessage[] = [{ role: "system", content: systemParts.join("\n") }];
const roleVisitIndices: number[] = [];
for (let i = 0; i < ctx.steps.length; i++) {
const step = ctx.steps[i];
if (step !== undefined && step.role === ctx.role) {
roleVisitIndices.push(i);
}
}
let prevVisitIndex = -1;
for (const visitIndex of roleVisitIndices) {
const visitStep = ctx.steps[visitIndex];
if (visitStep === undefined) {
continue;
}
const summary = buildStepsSummary(ctx.steps, prevVisitIndex + 1, visitIndex);
messages.push({
role: "user",
content: buildUserTurnContent(visitStep.edgePrompt, summary),
});
messages.push({
role: "assistant",
content: JSON.stringify(visitStep.output),
tool_calls: null,
});
prevVisitIndex = visitIndex;
}
const finalSummary = buildStepsSummary(ctx.steps, prevVisitIndex + 1, ctx.steps.length);
messages.push({
role: "user",
content: buildUserTurnContent(ctx.edgePrompt, finalSummary),
});
return messages;
}
@@ -1,45 +0,0 @@
import type { JSONSchema } from "@uncaged/json-cas";
const BUILTIN_TOOL_CALL_SCHEMA: JSONSchema = {
type: "object",
required: ["name", "args"],
properties: {
name: { type: "string" },
args: { type: "string" },
},
additionalProperties: false,
};
export const BUILTIN_TURN_SCHEMA: JSONSchema = {
title: "builtin-turn",
type: "object",
required: ["role", "content"],
properties: {
role: { type: "string", enum: ["assistant", "tool"] },
content: { type: "string" },
toolCalls: {
anyOf: [{ type: "array", items: BUILTIN_TOOL_CALL_SCHEMA }, { type: "null" }],
},
reasoning: {
anyOf: [{ type: "string" }, { type: "null" }],
},
},
additionalProperties: false,
};
export const BUILTIN_DETAIL_SCHEMA: JSONSchema = {
title: "builtin-detail",
type: "object",
required: ["sessionId", "model", "duration", "turnCount", "turns"],
properties: {
sessionId: { type: "string" },
model: { type: "string" },
duration: { type: "integer" },
turnCount: { type: "integer" },
turns: {
type: "array",
items: { type: "string", format: "cas_ref" },
},
},
additionalProperties: false,
};
@@ -1,59 +0,0 @@
import { appendFile, mkdir, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { createLogger } from "@uncaged/workflow-util";
import type { BuiltinTurnPayload } from "./types.js";
const log = createLogger({ sink: { kind: "stderr" } });
function sessionsDir(storageRoot: string): string {
return join(storageRoot, "sessions");
}
function sessionFile(storageRoot: string, sessionId: string): string {
return join(sessionsDir(storageRoot), `${sessionId}.jsonl`);
}
/** Ensure sessions directory exists. */
export async function initSessionDir(storageRoot: string): Promise<void> {
await mkdir(sessionsDir(storageRoot), { recursive: true });
}
/** Append a turn to the session jsonl file. */
export async function appendSessionTurn(
storageRoot: string,
sessionId: string,
turn: BuiltinTurnPayload,
): Promise<void> {
const line = `${JSON.stringify(turn)}\n`;
await appendFile(sessionFile(storageRoot, sessionId), line, "utf-8");
log("3XQVN8KR", `session ${sessionId} appended ${turn.role} turn`);
}
/** Read all turns from session jsonl. Returns empty array if file does not exist. */
export async function readSessionTurns(
storageRoot: string,
sessionId: string,
): Promise<BuiltinTurnPayload[]> {
try {
const content = await readFile(sessionFile(storageRoot, sessionId), "utf-8");
const lines = content
.trim()
.split("\n")
.filter((l) => l.length > 0);
return lines.map((l) => JSON.parse(l) as BuiltinTurnPayload);
} catch {
return [];
}
}
/** Remove session jsonl file (called after detail is persisted to step CAS). */
export async function removeSession(storageRoot: string, sessionId: string): Promise<void> {
try {
await rm(sessionFile(storageRoot, sessionId));
log("7FWDP2MJ", `session ${sessionId} removed`);
} catch {
// already gone — fine
}
}
@@ -1,44 +0,0 @@
import type { OpenAiToolDefinition } from "../llm/index.js";
import { readFileTool } from "./read-file.js";
import { runCommandTool } from "./run-command.js";
import type { BuiltinTool, ToolContext } from "./types.js";
import { writeFileTool } from "./write-file.js";
export { resolvePath } from "./path.js";
export type { BuiltinTool, ToolContext } from "./types.js";
const BUILTIN_TOOLS: BuiltinTool[] = [readFileTool, writeFileTool, runCommandTool];
export function getBuiltinTools(): readonly BuiltinTool[] {
return BUILTIN_TOOLS;
}
export function builtinToolsToOpenAi(tools: readonly BuiltinTool[]): OpenAiToolDefinition[] {
return tools.map((tool) => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters as Record<string, unknown>,
},
}));
}
export async function executeBuiltinTool(
name: string,
argsJson: string,
ctx: ToolContext,
): Promise<string> {
const tool = BUILTIN_TOOLS.find((t) => t.name === name);
if (tool === undefined) {
return `Error: unknown tool ${name}`;
}
let args: unknown;
try {
args = JSON.parse(argsJson) as unknown;
} catch {
return "Error: tool arguments must be valid JSON";
}
return tool.execute(args, ctx);
}
@@ -1,6 +0,0 @@
import { resolve } from "node:path";
/** Resolve a path relative to the working directory. */
export function resolvePath(cwd: string, inputPath: string): string {
return resolve(cwd, inputPath);
}
@@ -1,41 +0,0 @@
import { readFile, stat } from "node:fs/promises";
import { resolvePath } from "./path.js";
import type { BuiltinTool } from "./types.js";
const MAX_READ_BYTES = 512 * 1024;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export const readFileTool: BuiltinTool = {
name: "read_file",
description: "Read a UTF-8 text file from the workspace.",
parameters: {
type: "object",
required: ["path"],
properties: {
path: { type: "string", description: "Relative or absolute path within the workspace." },
},
additionalProperties: false,
},
execute: async (args, ctx) => {
if (!isRecord(args) || typeof args.path !== "string") {
return "Error: path must be a string";
}
const resolved = resolvePath(ctx.cwd, args.path);
try {
const info = await stat(resolved);
if (!info.isFile()) {
return "Error: not a file";
}
if (info.size > MAX_READ_BYTES) {
return `Error: file exceeds ${MAX_READ_BYTES} byte limit`;
}
return await readFile(resolved, "utf8");
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
return `Error: ${message}`;
}
},
};
@@ -1,95 +0,0 @@
import { spawn } from "node:child_process";
import { resolvePath } from "./path.js";
import type { BuiltinTool } from "./types.js";
const COMMAND_TIMEOUT_MS = 60_000;
const MAX_OUTPUT_CHARS = 32_000;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function truncate(text: string, maxChars: number): string {
if (text.length <= maxChars) {
return text;
}
return `${text.slice(0, maxChars)}\n...(truncated)`;
}
function runShell(
command: string,
cwd: string,
): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve, reject) => {
const child = spawn(command, {
cwd,
env: process.env,
shell: true,
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();
});
const timer = setTimeout(() => {
child.kill("SIGTERM");
}, COMMAND_TIMEOUT_MS);
child.on("error", (cause) => {
clearTimeout(timer);
const message = cause instanceof Error ? cause.message : String(cause);
reject(new Error(message));
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({ stdout, stderr, code: code ?? 1 });
});
});
}
export const runCommandTool: BuiltinTool = {
name: "run_command",
description: "Run a shell command. Output is truncated to 32KB.",
parameters: {
type: "object",
required: ["command"],
properties: {
command: { type: "string", description: "Shell command to execute." },
cwd: {
type: "string",
description: "Optional working directory relative to workspace root.",
},
},
additionalProperties: false,
},
execute: async (args, ctx) => {
if (!isRecord(args) || typeof args.command !== "string") {
return "Error: command must be a string";
}
let workDir = ctx.cwd;
if (args.cwd !== undefined && args.cwd !== null) {
if (typeof args.cwd !== "string") {
return "Error: cwd must be a string";
}
workDir = resolvePath(ctx.cwd, args.cwd);
}
try {
const { stdout, stderr, code } = await runShell(args.command, workDir);
const out = truncate(
`exit_code: ${code}\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
MAX_OUTPUT_CHARS,
);
return out;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
return `Error: ${message}`;
}
},
};
@@ -1,13 +0,0 @@
import type { JSONSchema } from "@uncaged/json-cas";
export type ToolContext = {
cwd: string;
storageRoot: string;
};
export type BuiltinTool = {
name: string;
description: string;
parameters: JSONSchema;
execute: (args: unknown, ctx: ToolContext) => Promise<string>;
};
@@ -1,36 +0,0 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { resolvePath } from "./path.js";
import type { BuiltinTool } from "./types.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export const writeFileTool: BuiltinTool = {
name: "write_file",
description: "Write UTF-8 text to a file in the workspace (creates parent directories).",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string", description: "Relative or absolute path within the workspace." },
content: { type: "string", description: "File contents to write." },
},
additionalProperties: false,
},
execute: async (args, ctx) => {
if (!isRecord(args) || typeof args.path !== "string" || typeof args.content !== "string") {
return "Error: path and content must be strings";
}
const resolved = resolvePath(ctx.cwd, args.path);
try {
await mkdir(dirname(resolved), { recursive: true });
await writeFile(resolved, args.content, "utf8");
return `Wrote ${args.content.length} bytes to ${args.path}`;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
return `Error: ${message}`;
}
},
};
@@ -1,49 +0,0 @@
import type { ChatMessage } from "./llm/index.js";
export type BuiltinToolCallRecord = {
id: string;
name: string;
args: string;
};
export type BuiltinToolResultRecord = {
toolCallId: string;
name: string;
content: string;
};
export type BuiltinLoopTurn = {
assistantContent: string | null;
toolCalls: BuiltinToolCallRecord[] | null;
toolResults: BuiltinToolResultRecord[] | null;
};
export type BuiltinSessionState = {
sessionId: string;
model: string;
startedAtMs: number;
messages: ChatMessage[];
turns: BuiltinLoopTurn[];
};
export type BuiltinTurnRole = "assistant" | "tool";
export type BuiltinToolCall = {
name: string;
args: string;
};
export type BuiltinTurnPayload = {
role: BuiltinTurnRole;
content: string;
toolCalls: BuiltinToolCall[] | null;
reasoning: string | null;
};
export type BuiltinDetailPayload = {
sessionId: string;
model: string;
duration: number;
turnCount: number;
turns: string[];
};
@@ -1,9 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [{ "path": "../workflow-agent-kit" }, { "path": "../workflow-util" }]
}
@@ -1,91 +0,0 @@
# @uncaged/workflow-agent-claude-code
`uwf-claude-code` agent — spawns the Claude Code CLI and captures session detail.
## Overview
Layer 3 agent implementation. Spawns the `claude` CLI with a composed system prompt (role definition, task, prior steps, edge prompt). Parses stream or JSON stdout, caches session IDs for multi-turn continuation, and stores raw output plus structured detail in CAS.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-agent-kit`
## Installation
Included as the `uwf-claude-code` binary when you install `@uncaged/workflow-agent-claude-code`:
```bash
bun add -g @uncaged/workflow-agent-claude-code
```
Requires the `claude` CLI on `PATH`.
## CLI Usage
Invoked by `uwf thread step`:
```bash
uwf-claude-code <thread-id> <role>
```
Configure or override the agent:
```bash
uwf setup --agent claude-code
uwf thread step <thread-id> --agent uwf-claude-code
```
Environment variables set by the engine:
| Variable | Purpose |
|----------|---------|
| `UWF_EDGE_PROMPT` | Moderator edge instruction for this step |
## API
All exports come from `src/index.ts`.
### Agent factory
```typescript
function createClaudeCodeAgent(): () => Promise<void>
function buildClaudeCodePrompt(ctx: AgentContext): string
```
### Session detail
```typescript
function parseClaudeCodeStreamOutput(stdout: string): ClaudeCodeParsedResult | null
function parseClaudeCodeJsonOutput(stdout: string): ClaudeCodeParsedResult | null
function storeClaudeCodeDetail(
store: Store,
parsed: ClaudeCodeParsedResult,
sessionId: string,
): Promise<string>
function storeClaudeCodeRawOutput(store: Store, rawOutput: string): Promise<string>
```
## Usage (library)
```typescript
import { createClaudeCodeAgent, buildClaudeCodePrompt } from "@uncaged/workflow-agent-claude-code";
const main = createClaudeCodeAgent();
void main();
```
## Internal Structure
```
src/
├── index.ts
├── cli.ts Binary entrypoint
├── claude-code.ts createClaudeCodeAgent, buildClaudeCodePrompt, spawn logic
├── session-detail.ts Parse stdout, store CAS detail nodes
├── schemas.ts Claude Code detail CAS schemas
└── types.ts ClaudeCodeParsedResult, message shapes
```
## Configuration
Uses session caching from `@uncaged/workflow-agent-kit` (`getCachedSessionId` / `setCachedSessionId`). No separate config file — relies on the Claude Code CLI's own authentication.
Maximum turns per invocation: 90 (constant in `claude-code.ts`).
@@ -41,15 +41,7 @@ describe("buildClaudeCodePrompt", () => {
test("includes previous steps as history summary", () => {
const ctx = makeCtx({
steps: [
{
role: "planner",
output: '{"plan":"do X"}',
agent: "hermes",
detail: "detail-1",
edgePrompt: "Create a plan.",
},
],
steps: [{ role: "planner", output: '{"plan":"do X"}', agent: "hermes" }],
});
const result = buildClaudeCodePrompt(ctx);
expect(result).toContain("## Previous Steps");
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test";
import { createMemoryStore, walk } from "@uncaged/json-cas";
import {
parseClaudeCodeJsonOutput,
parseClaudeCodeStreamOutput,
storeClaudeCodeDetail,
storeClaudeCodeRawOutput,
} from "../src/session-detail.js";
@@ -18,8 +17,6 @@ describe("parseClaudeCodeJsonOutput", () => {
num_turns: 3,
total_cost_usd: 0.08,
duration_ms: 10276,
stop_reason: "end_turn",
usage: { input_tokens: 100, output_tokens: 50 },
});
const parsed = parseClaudeCodeJsonOutput(stdout);
expect(parsed).not.toBeNull();
@@ -30,10 +27,22 @@ describe("parseClaudeCodeJsonOutput", () => {
expect(parsed!.numTurns).toBe(3);
expect(parsed!.totalCostUsd).toBe(0.08);
expect(parsed!.durationMs).toBe(10276);
expect(parsed!.stopReason).toBe("end_turn");
expect(parsed!.usage.inputTokens).toBe(100);
expect(parsed!.usage.outputTokens).toBe(50);
expect(parsed!.turns).toEqual([]);
});
test("parses error_max_turns result", () => {
const stdout = JSON.stringify({
type: "result",
subtype: "error_max_turns",
result: "Ran out of turns",
session_id: "abc-def",
num_turns: 90,
total_cost_usd: 1.5,
duration_ms: 50000,
});
const parsed = parseClaudeCodeJsonOutput(stdout);
expect(parsed).not.toBeNull();
expect(parsed!.subtype).toBe("error_max_turns");
expect(parsed!.result).toBe("Ran out of turns");
});
test("returns null for non-JSON output", () => {
@@ -48,160 +57,45 @@ describe("parseClaudeCodeJsonOutput", () => {
});
});
describe("parseClaudeCodeStreamOutput", () => {
test("parses stream-json output with turns", () => {
const lines = [
JSON.stringify({
type: "system",
subtype: "init",
session_id: "sess-123",
model: "claude-sonnet-4.5",
tools: ["Bash", "Read"],
}),
JSON.stringify({
type: "assistant",
message: {
role: "assistant",
content: [
{ type: "text", text: "I'll list the files." },
{ type: "tool_use", id: "tool_1", name: "Bash", input: { command: "ls" } },
],
},
session_id: "sess-123",
}),
JSON.stringify({
type: "user",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "tool_1", content: "file1.ts\nfile2.ts" }],
},
session_id: "sess-123",
}),
JSON.stringify({
type: "assistant",
message: {
role: "assistant",
content: [{ type: "text", text: "There are 2 files." }],
},
session_id: "sess-123",
}),
JSON.stringify({
type: "result",
subtype: "success",
result: "There are 2 files.",
session_id: "sess-123",
num_turns: 2,
total_cost_usd: 0.05,
duration_ms: 5000,
stop_reason: "end_turn",
usage: {
input_tokens: 200,
output_tokens: 30,
cache_read_input_tokens: 100,
cache_creation_input_tokens: 0,
},
}),
];
const stdout = lines.join("\n");
const parsed = parseClaudeCodeStreamOutput(stdout);
expect(parsed).not.toBeNull();
expect(parsed!.model).toBe("claude-sonnet-4.5");
expect(parsed!.sessionId).toBe("sess-123");
expect(parsed!.result).toBe("There are 2 files.");
expect(parsed!.stopReason).toBe("end_turn");
expect(parsed!.usage.inputTokens).toBe(200);
expect(parsed!.usage.outputTokens).toBe(30);
expect(parsed!.usage.cacheReadInputTokens).toBe(100);
// Turns: assistant(text+tool), tool_result, assistant(text)
expect(parsed!.turns).toHaveLength(3);
expect(parsed!.turns[0]!.role).toBe("assistant");
expect(parsed!.turns[0]!.content).toBe("I'll list the files.");
expect(parsed!.turns[0]!.toolCalls).toHaveLength(1);
expect(parsed!.turns[0]!.toolCalls![0]!.name).toBe("Bash");
expect(parsed!.turns[1]!.role).toBe("tool_result");
expect(parsed!.turns[1]!.content).toBe("file1.ts\nfile2.ts");
expect(parsed!.turns[2]!.role).toBe("assistant");
expect(parsed!.turns[2]!.content).toBe("There are 2 files.");
expect(parsed!.turns[2]!.toolCalls).toBeNull();
});
test("returns null when no result line", () => {
const stdout = JSON.stringify({ type: "system", model: "test" });
expect(parseClaudeCodeStreamOutput(stdout)).toBeNull();
});
test("skips invalid JSON lines gracefully", () => {
const lines = [
"not json",
JSON.stringify({
type: "result",
subtype: "success",
result: "ok",
session_id: "s1",
num_turns: 1,
total_cost_usd: 0.01,
duration_ms: 1000,
stop_reason: "end_turn",
usage: {},
}),
];
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
expect(parsed).not.toBeNull();
expect(parsed!.result).toBe("ok");
expect(parsed!.turns).toHaveLength(0);
});
});
describe("storeClaudeCodeDetail", () => {
const baseParsed: ClaudeCodeParsedResult = {
type: "result",
subtype: "success",
result: "The answer",
sessionId: "abc-123",
numTurns: 5,
totalCostUsd: 0.12,
durationMs: 15000,
model: "claude-sonnet-4.5",
stopReason: "end_turn",
usage: {
inputTokens: 100,
outputTokens: 50,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
},
turns: [
{ index: 0, role: "assistant", content: "hello", toolCalls: null },
{ index: 1, role: "tool_result", content: "world", toolCalls: null },
],
};
test("stores detail with per-turn CAS nodes", async () => {
test("stores claude-code-detail CAS node and returns output + detailHash", async () => {
const store = createMemoryStore();
const { detailHash, output, sessionId } = await storeClaudeCodeDetail(store, baseParsed);
const parsed: ClaudeCodeParsedResult = {
type: "result",
subtype: "success",
result: "The answer",
sessionId: "abc-123",
numTurns: 5,
totalCostUsd: 0.12,
durationMs: 15000,
};
const { detailHash, output, sessionId } = await storeClaudeCodeDetail(store, parsed);
expect(detailHash).toHaveLength(13);
expect(output).toBe("The answer");
expect(sessionId).toBe("abc-123");
const node = await store.get(detailHash);
expect(node).not.toBeNull();
expect(node!.payload.model).toBe("claude-sonnet-4.5");
expect(node!.payload.stopReason).toBe("end_turn");
expect(node!.payload.usage.inputTokens).toBe(100);
expect(node!.payload.turns).toHaveLength(2);
// Verify turn CAS nodes
const turn0 = await store.get(node!.payload.turns[0]);
expect(turn0).not.toBeNull();
expect(turn0!.payload.role).toBe("assistant");
expect(turn0!.payload.content).toBe("hello");
expect(node!.payload.sessionId).toBe("abc-123");
expect(node!.payload.numTurns).toBe(5);
expect(node!.payload.totalCostUsd).toBe(0.12);
expect(node!.payload.durationMs).toBe(15000);
});
test("detail node is walkable from root", async () => {
const store = createMemoryStore();
const { detailHash } = await storeClaudeCodeDetail(store, baseParsed);
const parsed: ClaudeCodeParsedResult = {
type: "result",
subtype: "success",
result: "walkable test",
sessionId: "walk-123",
numTurns: 1,
totalCostUsd: 0.01,
durationMs: 1000,
};
const { detailHash } = await storeClaudeCodeDetail(store, parsed);
const visited: string[] = [];
walk(store, detailHash, (hash) => visited.push(hash));
expect(visited.length).toBeGreaterThan(0);
@@ -22,7 +22,8 @@
},
"dependencies": {
"@uncaged/json-cas": "^0.4.0",
"@uncaged/workflow-agent-kit": "workspace:^"
"@uncaged/workflow-agent-kit": "workspace:^",
"@uncaged/workflow-util": "workspace:^"
},
"devDependencies": {
"typescript": "^5.8.3"
@@ -1,5 +1,6 @@
import { spawn } from "node:child_process";
import type { Store } from "@uncaged/json-cas";
import {
type AgentContext,
type AgentRunResult,
@@ -10,13 +11,13 @@ import {
} from "@uncaged/workflow-agent-kit";
import { createLogger } from "@uncaged/workflow-util";
import { parseClaudeCodeStreamOutput, storeClaudeCodeDetail } from "./session-detail.js";
const log = createLogger({ sink: { kind: "stderr" } });
import { parseClaudeCodeJsonOutput, storeClaudeCodeDetail } from "./session-detail.js";
const CLAUDE_COMMAND = "claude";
const CLAUDE_MAX_TURNS = 90;
const log = createLogger({ sink: { kind: "stderr" } });
function buildHistorySummary(steps: AgentContext["steps"]): string {
if (steps.length === 0) {
return "";
@@ -49,7 +50,6 @@ export function buildClaudeCodePrompt(ctx: AgentContext): string {
if (historyBlock !== "") {
parts.push("", historyBlock);
}
parts.push("", "## Current Instruction", "", ctx.edgePrompt);
return parts.join("\n");
}
@@ -91,8 +91,7 @@ function spawnClaudeRun(prompt: string): Promise<{ stdout: string; stderr: strin
"-p",
prompt,
"--output-format",
"stream-json",
"--verbose",
"json",
"--dangerously-skip-permissions",
"--max-turns",
String(CLAUDE_MAX_TURNS),
@@ -109,8 +108,7 @@ function spawnClaudeResume(
"--resume",
sessionId,
"--output-format",
"stream-json",
"--verbose",
"json",
"--dangerously-skip-permissions",
"--max-turns",
String(CLAUDE_MAX_TURNS),
@@ -118,7 +116,7 @@ function spawnClaudeResume(
}
async function processClaudeOutput(stdout: string, store: Store): Promise<AgentRunResult> {
const parsed = parseClaudeCodeStreamOutput(stdout);
const parsed = parseClaudeCodeJsonOutput(stdout);
if (parsed !== null) {
const { detailHash, output, sessionId } = await storeClaudeCodeDetail(store, parsed);
@@ -126,15 +124,13 @@ async function processClaudeOutput(stdout: string, store: Store): Promise<AgentR
}
throw new Error(
`Claude Code returned unparseable output (first 200 chars): ${stdout.slice(0, 200)}`,
`Claude Code returned non-JSON output (first 200 chars): ${stdout.slice(0, 200)}`,
);
}
async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
const fullPrompt = buildClaudeCodePrompt(ctx);
log("K7R2M4N8", `prompt for role=${ctx.role} (length=${fullPrompt.length}):\n${fullPrompt}`);
// Try resuming a cached session for re-entry scenarios (e.g. reviewer reject → developer re-entry).
if (!ctx.isFirstVisit) {
const cachedSessionId = await getCachedSessionId(ctx.threadId, ctx.role);
@@ -142,24 +138,20 @@ async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
try {
const { stdout } = await spawnClaudeResume(cachedSessionId, fullPrompt);
const result = await processClaudeOutput(stdout, ctx.store);
if (result.sessionId !== undefined && result.sessionId !== "") {
if (result.sessionId !== "") {
await setCachedSessionId(ctx.threadId, ctx.role, result.sessionId);
}
return result;
} catch (err) {
log(
"5VKR8N3Q",
"resume failed for session %s, falling back to fresh run: %s",
cachedSessionId,
err,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log("5VKR8N3Q", `session resume failed, falling back to new session: ${message}`);
}
}
}
const { stdout } = await spawnClaudeRun(fullPrompt);
const result = await processClaudeOutput(stdout, ctx.store);
if (result.sessionId !== undefined && result.sessionId !== "") {
if (result.sessionId !== "") {
await setCachedSessionId(ctx.threadId, ctx.role, result.sessionId);
}
return result;
@@ -1,7 +1,6 @@
export { buildClaudeCodePrompt, createClaudeCodeAgent } from "./claude-code.js";
export {
parseClaudeCodeJsonOutput,
parseClaudeCodeStreamOutput,
storeClaudeCodeDetail,
storeClaudeCodeRawOutput,
} from "./session-detail.js";
@@ -3,52 +3,13 @@ import type { JSONSchema } from "@uncaged/json-cas";
export const CLAUDE_CODE_DETAIL_SCHEMA: JSONSchema = {
title: "claude-code-detail",
type: "object",
required: [
"sessionId",
"model",
"subtype",
"durationMs",
"numTurns",
"totalCostUsd",
"stopReason",
"usage",
"turns",
],
required: ["sessionId", "numTurns", "totalCostUsd", "durationMs", "subtype"],
properties: {
sessionId: { type: "string" },
model: { type: "string" },
subtype: { type: "string" },
durationMs: { type: "integer" },
numTurns: { type: "integer" },
totalCostUsd: { type: "number" },
stopReason: { type: "string" },
usage: {
type: "object",
properties: {
inputTokens: { type: "integer" },
outputTokens: { type: "integer" },
cacheReadInputTokens: { type: "integer" },
cacheCreationInputTokens: { type: "integer" },
},
required: ["inputTokens", "outputTokens", "cacheReadInputTokens", "cacheCreationInputTokens"],
},
turns: {
type: "array",
items: { type: "string" },
},
},
additionalProperties: false,
};
export const CLAUDE_CODE_TURN_SCHEMA: JSONSchema = {
title: "claude-code-turn",
type: "object",
required: ["index", "role", "content", "toolCalls"],
properties: {
index: { type: "integer" },
role: { type: "string" },
content: { type: "string" },
toolCalls: {},
durationMs: { type: "integer" },
subtype: { type: "string" },
},
additionalProperties: false,
};
@@ -1,171 +1,13 @@
import { bootstrap, putSchema, type Store } from "@uncaged/json-cas";
import {
CLAUDE_CODE_DETAIL_SCHEMA,
CLAUDE_CODE_RAW_OUTPUT_SCHEMA,
CLAUDE_CODE_TURN_SCHEMA,
} from "./schemas.js";
import type {
ClaudeCodeDetailPayload,
ClaudeCodeParsedResult,
ClaudeCodeToolCall,
ClaudeCodeTurnPayload,
} from "./types.js";
import { CLAUDE_CODE_DETAIL_SCHEMA, CLAUDE_CODE_RAW_OUTPUT_SCHEMA } from "./schemas.js";
import type { ClaudeCodeDetailPayload, ClaudeCodeParsedResult } from "./types.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function safeNumber(v: unknown, fallback = 0): number {
return typeof v === "number" ? v : fallback;
}
function safeString(v: unknown, fallback = ""): string {
return typeof v === "string" ? v : fallback;
}
/**
* Extract tool calls from an assistant message content array.
*/
function extractToolCalls(content: unknown[]): ClaudeCodeToolCall[] {
const calls: ClaudeCodeToolCall[] = [];
for (const item of content) {
if (isRecord(item) && item.type === "tool_use" && typeof item.name === "string") {
calls.push({
name: item.name,
input: typeof item.input === "string" ? item.input : JSON.stringify(item.input ?? {}),
});
}
}
return calls;
}
/**
* Extract text content from a message content array.
*/
function extractTextContent(content: unknown[]): string {
const texts: string[] = [];
for (const item of content) {
if (isRecord(item) && item.type === "text" && typeof item.text === "string") {
texts.push(item.text);
}
}
return texts.join("\n");
}
/**
* Extract tool result content from a user message content array.
*/
function extractToolResultContent(content: unknown[]): string {
const results: string[] = [];
for (const item of content) {
if (isRecord(item) && item.type === "tool_result") {
const text = typeof item.content === "string" ? item.content : "";
results.push(text);
}
}
return results.join("\n");
}
/**
* Parse Claude Code stream-json (NDJSON) output.
* Each line is a JSON object with type: "system" | "assistant" | "user" | "result".
*/
export function parseClaudeCodeStreamOutput(stdout: string): ClaudeCodeParsedResult | null {
const lines = stdout.trim().split("\n");
const turns: ClaudeCodeTurnPayload[] = [];
let resultLine: Record<string, unknown> | null = null;
let model = "";
let turnIndex = 0;
for (const line of lines) {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
continue;
}
if (!isRecord(parsed)) continue;
const type = parsed.type;
if (type === "system" && typeof parsed.model === "string") {
model = parsed.model;
}
if (type === "assistant" && isRecord(parsed.message)) {
const msg = parsed.message;
const content = Array.isArray(msg.content) ? msg.content : [];
const textContent = extractTextContent(content as unknown[]);
const toolCalls = extractToolCalls(content as unknown[]);
// Only record turns that have actual content
if (textContent !== "" || toolCalls.length > 0) {
turns.push({
index: turnIndex++,
role: "assistant",
content: textContent,
toolCalls: toolCalls.length > 0 ? toolCalls : null,
});
}
}
if (type === "user" && isRecord(parsed.message)) {
const msg = parsed.message;
const content = Array.isArray(msg.content) ? msg.content : [];
const resultContent = extractToolResultContent(content as unknown[]);
if (resultContent !== "") {
turns.push({
index: turnIndex++,
role: "tool_result",
content: resultContent,
toolCalls: null,
});
}
}
if (type === "result") {
resultLine = parsed;
}
}
if (resultLine === null) return null;
const sessionId = resultLine.session_id;
const result = resultLine.result;
const subtype = resultLine.subtype;
if (typeof sessionId !== "string" || typeof result !== "string" || typeof subtype !== "string") {
return null;
}
const usage = isRecord(resultLine.usage) ? resultLine.usage : {};
return {
type: safeString(resultLine.type, "result"),
subtype: subtype as ClaudeCodeParsedResult["subtype"],
result,
sessionId,
numTurns: safeNumber(resultLine.num_turns),
totalCostUsd: safeNumber(resultLine.total_cost_usd),
durationMs: safeNumber(resultLine.duration_ms),
model,
stopReason: safeString(resultLine.stop_reason),
usage: {
inputTokens: safeNumber(usage.input_tokens),
outputTokens: safeNumber(usage.output_tokens),
cacheReadInputTokens: safeNumber(usage.cache_read_input_tokens),
cacheCreationInputTokens: safeNumber(usage.cache_creation_input_tokens),
},
turns,
};
}
/**
* Legacy: parse Claude Code plain JSON output (non-streaming).
* Falls back when stream-json is not available.
*/
/** Parse Claude Code JSON stdout (`claude -p --output-format json`). */
export function parseClaudeCodeJsonOutput(stdout: string): ClaudeCodeParsedResult | null {
let parsed: unknown;
try {
@@ -174,7 +16,9 @@ export function parseClaudeCodeJsonOutput(stdout: string): ClaudeCodeParsedResul
return null;
}
if (!isRecord(parsed)) return null;
if (!isRecord(parsed)) {
return null;
}
const sessionId = parsed.session_id;
const result = parsed.result;
@@ -184,68 +28,44 @@ export function parseClaudeCodeJsonOutput(stdout: string): ClaudeCodeParsedResul
return null;
}
const usage = isRecord(parsed.usage) ? parsed.usage : {};
return {
type: safeString(parsed.type, "result"),
type: typeof parsed.type === "string" ? parsed.type : "result",
subtype: subtype as ClaudeCodeParsedResult["subtype"],
result,
sessionId,
numTurns: safeNumber(parsed.num_turns),
totalCostUsd: safeNumber(parsed.total_cost_usd),
durationMs: safeNumber(parsed.duration_ms),
model: "",
stopReason: safeString(parsed.stop_reason),
usage: {
inputTokens: safeNumber(usage.input_tokens),
outputTokens: safeNumber(usage.output_tokens),
cacheReadInputTokens: safeNumber(usage.cache_read_input_tokens),
cacheCreationInputTokens: safeNumber(usage.cache_creation_input_tokens),
},
turns: [],
numTurns: typeof parsed.num_turns === "number" ? parsed.num_turns : 0,
totalCostUsd: typeof parsed.total_cost_usd === "number" ? parsed.total_cost_usd : 0,
durationMs: typeof parsed.duration_ms === "number" ? parsed.duration_ms : 0,
};
}
type ClaudeCodeSchemaHashes = {
detail: string;
turn: string;
rawOutput: string;
};
async function registerSchemas(store: Store): Promise<ClaudeCodeSchemaHashes> {
await bootstrap(store);
const [detail, turn, rawOutput] = await Promise.all([
const [detail, rawOutput] = await Promise.all([
putSchema(store, CLAUDE_CODE_DETAIL_SCHEMA),
putSchema(store, CLAUDE_CODE_TURN_SCHEMA),
putSchema(store, CLAUDE_CODE_RAW_OUTPUT_SCHEMA),
]);
return { detail, turn, rawOutput };
return { detail, rawOutput };
}
/** Store parsed Claude Code result with per-turn breakdown as CAS detail nodes. */
/** Store parsed Claude Code result as a CAS detail node. */
export async function storeClaudeCodeDetail(
store: Store,
parsed: ClaudeCodeParsedResult,
): Promise<{ detailHash: string; output: string; sessionId: string }> {
const schemas = await registerSchemas(store);
// Store each turn as an individual CAS node
const turnHashes: string[] = [];
for (const turn of parsed.turns) {
const hash = await store.put(schemas.turn, turn);
turnHashes.push(hash);
}
const detail: ClaudeCodeDetailPayload = {
sessionId: parsed.sessionId,
model: parsed.model,
subtype: parsed.subtype,
durationMs: parsed.durationMs,
numTurns: parsed.numTurns,
totalCostUsd: parsed.totalCostUsd,
stopReason: parsed.stopReason,
usage: parsed.usage,
turns: turnHashes,
durationMs: parsed.durationMs,
subtype: parsed.subtype,
};
const detailHash = await store.put(schemas.detail, detail);
@@ -1,38 +1,5 @@
export type ClaudeCodeResultSubtype = "success" | "error_max_turns" | "error_budget";
/** A single tool call within an assistant turn. */
export type ClaudeCodeToolCall = {
name: string;
input: string;
};
/** A single turn (assistant text, tool use, or tool result). */
export type ClaudeCodeTurnPayload = {
index: number;
role: "assistant" | "tool_result";
content: string;
toolCalls: ClaudeCodeToolCall[] | null;
};
/** Top-level detail stored as CAS node. */
export type ClaudeCodeDetailPayload = {
sessionId: string;
model: string;
subtype: string;
durationMs: number;
numTurns: number;
totalCostUsd: number;
stopReason: string;
usage: {
inputTokens: number;
outputTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
};
turns: string[]; // CAS hashes of ClaudeCodeTurnPayload
};
/** Intermediate parsed result from stream-json output. */
export type ClaudeCodeParsedResult = {
type: string;
subtype: ClaudeCodeResultSubtype;
@@ -41,13 +8,12 @@ export type ClaudeCodeParsedResult = {
numTurns: number;
totalCostUsd: number;
durationMs: number;
model: string;
stopReason: string;
usage: {
inputTokens: number;
outputTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
};
turns: ClaudeCodeTurnPayload[];
};
export type ClaudeCodeDetailPayload = {
sessionId: string;
numTurns: number;
totalCostUsd: number;
durationMs: number;
subtype: string;
};
-90
View File
@@ -1,90 +0,0 @@
# @uncaged/workflow-agent-hermes
`uwf-hermes` agent — spawns Hermes chat via ACP and captures session detail.
## Overview
Layer 3 agent implementation. Wraps the Hermes CLI using the Agent Client Protocol (ACP). On first visit to a role it sends a composed prompt (role definition, task, history, edge prompt); on continuation it resumes the cached session. Session transcripts and raw output are stored as CAS detail nodes.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/workflow-agent-kit`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`
## Installation
Included as the `uwf-hermes` binary when you install `@uncaged/workflow-agent-hermes`:
```bash
bun add -g @uncaged/workflow-agent-hermes
```
Requires the `hermes` CLI on `PATH`.
## CLI Usage
Invoked by `uwf thread step` (not typically run directly):
```bash
uwf-hermes <thread-id> <role>
```
Environment variables set by the engine:
| Variable | Purpose |
|----------|---------|
| `UWF_EDGE_PROMPT` | Moderator edge instruction for this step |
Configure as the default agent via `uwf setup --agent hermes`.
Override per step:
```bash
uwf thread step <thread-id> --agent uwf-hermes
```
## API
All exports come from `src/index.ts`.
### Agent factory
```typescript
function createHermesAgent(): () => Promise<void>
function buildHermesPrompt(ctx: AgentContext): string
```
### ACP client
```typescript
class HermesAcpClient {
// Spawns hermes, handles JSON-RPC over stdio
}
```
## Usage (library)
```typescript
import { createHermesAgent, buildHermesPrompt } from "@uncaged/workflow-agent-hermes";
// CLI entry (src/cli.ts):
const main = createHermesAgent();
void main();
```
## Internal Structure
```
src/
├── index.ts
├── cli.ts Binary entrypoint
├── hermes.ts createHermesAgent, buildHermesPrompt
├── acp-client.ts HermesAcpClient — ACP JSON-RPC over stdio
├── session-cache.ts Session ID cache (re-exports kit helpers + isResumeDisabled)
├── session-detail.ts Parse Hermes session JSON, store CAS detail nodes
├── schemas.ts Hermes detail CAS schemas
└── types.ts HermesSessionJson, HermesSessionMessage
```
## Configuration
Uses workflow config from `~/.uncaged/workflow/config.yaml` (via agent-kit). Hermes session files are stored under the workflow storage root (see `session-detail.ts`).
Set `UWF_HERMES_NO_RESUME=1` to disable session resume (see `isResumeDisabled` in `session-cache.ts`).
@@ -54,8 +54,7 @@ describe("HermesAcpClient", () => {
{ timeout: 2 * 60 * 1000 },
);
// TODO(#435): flaky — depends on live LLM; mock or move to integration suite
it.skip(
it(
"prompt() collects structured messages including tool calls",
async () => {
await client.connect(process.cwd());
@@ -49,20 +49,8 @@ describe("buildHermesPrompt", () => {
isFirstVisit: false,
edgePrompt: "The reviewer rejected your work. Fix the issues.",
steps: [
{
role: "developer",
output: { summary: "Initial fix" },
agent: "uwf-hermes",
detail: "detail-1",
edgePrompt: "Implement the fix.",
},
{
role: "reviewer",
output: { approved: false },
agent: "uwf-hermes",
detail: "detail-2",
edgePrompt: "Review the code.",
},
{ role: "developer", output: { summary: "Initial fix" }, agent: "uwf-hermes" },
{ role: "reviewer", output: { approved: false }, agent: "uwf-hermes" },
],
});
@@ -78,15 +66,7 @@ describe("buildHermesPrompt", () => {
const result = buildHermesPrompt(
makeCtx({
isFirstVisit: true,
steps: [
{
role: "developer",
output: { done: true },
agent: "uwf-hermes",
detail: "detail-1",
edgePrompt: "First attempt.",
},
],
steps: [{ role: "developer", output: { done: true }, agent: "uwf-hermes" }],
edgePrompt: "Retry with a fresh approach.",
}),
);
@@ -21,8 +21,7 @@ describe("HermesAcpClient cross-process resume", () => {
clients.length = 0;
});
// TODO(#435): flaky — depends on live LLM; mock or move to integration suite
it.skip(
it(
"resume() after close — second prompt returns non-empty text",
async () => {
// --- Client A: first run ---
@@ -267,7 +267,8 @@ export class HermesAcpClient {
case "tool_call": {
const title = (update.title as string) ?? "";
const rawInput = update.rawInput;
const args = rawInput !== undefined && rawInput !== null ? JSON.stringify(rawInput) : "";
const args =
rawInput !== undefined && rawInput !== null ? JSON.stringify(rawInput) : "";
const toolCallId = update.toolCallId as string;
this.pendingTools.set(toolCallId, { name: title, args });
-182
View File
@@ -1,182 +0,0 @@
# @uncaged/workflow-agent-kit
Agent framework — `createAgent` factory, context builder, frontmatter fast-path, and LLM extract pipeline.
## Overview
Layer 2 agent framework. Provides the standard entrypoint for all agent CLIs: parse `<thread-id> <role>` from argv, load thread/workflow context from CAS, invoke the agent's `run`/`continue` functions, validate output via frontmatter fast-path or LLM extract, and write a `StepNodePayload` to CAS.
Also exports prompt builders, config/storage helpers, and session ID caching for multi-turn agents.
**Dependencies:** `@uncaged/json-cas`, `@uncaged/json-cas-fs`, `@uncaged/workflow-protocol`, `@uncaged/workflow-util`, `dotenv`, `yaml`
## Installation
```bash
bun add @uncaged/workflow-agent-kit
```
## API
All exports come from `src/index.ts`.
### Agent factory
```typescript
function createAgent(options: AgentOptions): () => Promise<void>
type AgentOptions = {
name: string;
run: AgentRunFn;
continue: AgentContinueFn;
};
type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
type AgentContinueFn = (
sessionId: string,
message: string,
store: AgentContext["store"],
) => Promise<AgentRunResult>;
type AgentRunResult = {
output: string;
detailHash: string;
sessionId: string;
};
```
Agent CLIs call `createAgent(...)` and invoke the returned function as `main()`.
### Context
```typescript
function buildContext(threadId: ThreadId, role: string): Promise<AgentContext>
function buildContextWithMeta(
threadId: ThreadId,
role: string,
): Promise<AgentContext & { meta: BuildContextMeta }>
type AgentContext = ModeratorContext & {
threadId: ThreadId;
role: string;
store: Store;
workflow: WorkflowPayload;
outputFormatInstruction: string;
edgePrompt: string;
isFirstVisit: boolean;
};
type BuildContextMeta = {
storageRoot: string;
store: Store;
schemas: AgentStore["schemas"];
headHash: CasRef;
chain: ChainState;
};
```
Requires `UWF_EDGE_PROMPT` in the environment (set by `uwf thread step`).
### Prompt builders
```typescript
function buildRolePrompt(role: RoleDefinition): string
function buildOutputFormatInstruction(schema: JSONSchema): string
function buildContinuationPrompt(
ctx: AgentContext,
priorOutput: string,
instruction: string,
): string
```
### Extract pipeline
```typescript
function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias
function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider
function extract(
rawOutput: string,
outputSchema: CasRef,
config: WorkflowConfig,
): Promise<ExtractResult>
type ResolvedLlmProvider = { baseUrl: string; apiKey: string; model: string };
type ExtractResult = { value: unknown; hash: CasRef };
```
### Frontmatter fast-path
```typescript
function tryFrontmatterFastPath(
rawOutput: string,
outputSchema: CasRef,
store: Store,
): Promise<FrontmatterFastPathResult | null>
type FrontmatterFastPathResult = { body: string; outputHash: CasRef };
```
### Session cache
```typescript
function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null>
function setCachedSessionId(
threadId: ThreadId,
role: string,
sessionId: string,
): Promise<void>
```
### Config and storage
```typescript
function getConfigPath(storageRoot: string): string
function getEnvPath(storageRoot: string): string
function resolveStorageRoot(): string
function loadWorkflowConfig(storageRoot: string): Promise<WorkflowConfig>
```
## Usage
```typescript
import { createAgent, buildRolePrompt } from "@uncaged/workflow-agent-kit";
import type { AgentContext, AgentRunResult } from "@uncaged/workflow-agent-kit";
async function run(ctx: AgentContext): Promise<AgentRunResult> {
const prompt = buildRolePrompt(ctx.workflow.roles[ctx.role]!);
// ... spawn external process, capture output ...
return { output: markdown, detailHash: "...", sessionId: "..." };
}
async function continueSession(
sessionId: string,
message: string,
): Promise<AgentRunResult> {
// ... continue multi-turn session ...
return { output: markdown, detailHash: "...", sessionId };
}
export const main = createAgent({ name: "my-agent", run, continue: continueSession });
```
## Internal Structure
```
src/
├── index.ts
├── run.ts createAgent entrypoint
├── context.ts Thread chain walk, AgentContext builder
├── extract.ts LLM structured extract fallback
├── frontmatter.ts Frontmatter fast-path validation
├── build-role-prompt.ts Role definition → prompt text
├── build-output-format-instruction.ts
├── build-continuation-prompt.ts
├── session-cache.ts Per-thread/session ID persistence
├── storage.ts CAS store, config, threads index
├── schemas.ts Agent CAS schema registration
└── types.ts AgentContext, AgentOptions, etc.
```
## Configuration
Reads `config.yaml` and `.env` from the workflow storage root (`~/.uncaged/workflow` by default). See `@uncaged/workflow-protocol` for `WorkflowConfig` shape. Set via `uwf setup`.
@@ -7,7 +7,6 @@ const reviewerStep: StepContext = {
output: { approved: false, comments: "Missing tests" },
detail: "2MXBG6PN4A8JR",
agent: "uwf-hermes",
edgePrompt: "Review the developer's work.",
};
const developerStep: StepContext = {
@@ -15,7 +14,6 @@ const developerStep: StepContext = {
output: { filesChanged: ["src/app.ts"], summary: "Initial fix" },
detail: "1VPBG9SM5E7WK",
agent: "uwf-hermes",
edgePrompt: "Implement the fix.",
};
describe("buildContinuationPrompt", () => {
@@ -28,7 +26,6 @@ describe("buildContinuationPrompt", () => {
output: { plan: "revise approach" },
detail: "7BQST3VW9F2MA",
agent: "uwf-hermes",
edgePrompt: "Revise the plan.",
},
];
@@ -102,7 +102,6 @@ async function buildHistory(
output: expandOutput(store, step.output),
detail: step.detail,
agent: step.agent,
edgePrompt: step.edgePrompt ?? "",
});
}
return history;
+1 -1
View File
@@ -12,8 +12,8 @@ export {
export type { FrontmatterFastPathResult } from "./frontmatter.js";
export { tryFrontmatterFastPath } from "./frontmatter.js";
export { createAgent } from "./run.js";
export { getCachedSessionId, setCachedSessionId } from "./session-cache.js";
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
export { getCachedSessionId, setCachedSessionId } from "./session-cache.js";
export type {
AgentContext,
AgentContinueFn,
+1 -9
View File
@@ -50,7 +50,6 @@ async function writeStepNode(options: {
outputHash: CasRef;
detailHash: CasRef;
agentName: string;
edgePrompt: string;
}): Promise<CasRef> {
const payload: StepNodePayload = {
start: options.startHash,
@@ -59,7 +58,6 @@ async function writeStepNode(options: {
output: options.outputHash,
detail: options.detailHash,
agent: options.agentName,
edgePrompt: options.edgePrompt,
};
const hash = await options.store.put(options.schemas.stepNode, payload);
const node = options.store.get(hash);
@@ -97,7 +95,6 @@ async function persistStep(options: {
outputHash: options.outputHash,
detailHash: options.detailHash,
agentName: options.agentName,
edgePrompt: options.ctx.edgePrompt,
});
}
@@ -121,11 +118,6 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
let agentResult = await runWithMessage("agent run failed", () => options.run(ctx));
// Preserve the primary detail from the first run — it contains the full
// tool-call turn history. Continuation retries only fix frontmatter
// formatting and their 1-turn detail is not meaningful.
const primaryDetailHash = agentResult.detailHash;
// Try to extract frontmatter; retry via continue if it fails
let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
@@ -152,7 +144,7 @@ export function createAgent(options: AgentOptions): () => Promise<void> {
const stepHash = await persistStep({
ctx,
outputHash,
detailHash: primaryDetailHash,
detailHash: agentResult.detailHash,
agentName: agentLabel(options.name),
});
@@ -1,4 +1,3 @@
import { randomBytes } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
@@ -44,14 +43,18 @@ async function readCache(): Promise<SessionCache> {
}
}
/**
* Atomic write: write to a temp file, then rename.
* Prevents partial reads if another process reads mid-write.
* Note: read-modify-write is still not concurrency-safe across processes;
* the current workflow engine runs agent steps sequentially (execFileSync),
* so this is sufficient. If parallel execution is added later, a proper
* lockfile (e.g. proper-lockfile) will be needed.
*/
async function writeCache(cache: SessionCache): Promise<void> {
const path = getCachePath();
const dir = dirname(path);
await mkdir(dir, { recursive: true });
// Atomic write: write to temp file then rename to avoid partial reads on concurrent access.
// NOTE: Current workflow execution is serial (execFileSync), so true concurrency doesn't occur.
// This is a safety net for future parallel execution.
const tmpPath = join(dir, `.agent-sessions.${randomBytes(4).toString("hex")}.tmp`);
const tmpPath = `${path}.${process.pid}.tmp`;
await mkdir(dirname(path), { recursive: true });
await writeFile(tmpPath, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
await rename(tmpPath, path);
}
-84
View File
@@ -1,84 +0,0 @@
# @uncaged/workflow-dashboard
Web graph editor for visualizing and editing workflow YAML definitions.
## Overview
A private alpha web app (not part of the runtime engine stack). Provides a React + `@xyflow/react` canvas for editing workflow roles, conditions, and graph transitions. Uses `@uncaged/workflow-protocol` types for validation and YAML round-tripping.
Planned integration: local `uwf connect` over WebSocket to sync YAML between CLI and the browser editor. The REST API and Elysia backend are currently stubs for development.
**Dependencies:** `@uncaged/workflow-protocol`, `@xyflow/react`, React 19, react-router v7, Vite 8, Tailwind CSS v4, Elysia
## Installation
Monorepo-only ( `"private": true` ). Not published to npm.
```bash
cd packages/workflow-dashboard
bun install --no-cache
```
## CLI Usage
Start the Vite dev server (port 3000):
```bash
cd packages/workflow-dashboard
bun run dev
```
Build for production:
```bash
bun run build
```
Open `http://localhost:3000` in a browser.
## Internal Structure
```
workflow-dashboard/
├── server.ts Vite dev server entry (port 3000)
├── vite.config.ts Vite + React + Tailwind + Elysia plugin
├── vite-dev.ts Custom Vite plugin
├── index.html
├── components.json shadcn configuration
├── server/
│ ├── api.ts Elysia REST API (health + workflow CRUD stub)
│ └── workflow.ts Workflow file read/write + format conversion
└── src/
├── main.tsx React DOM entry
├── app.tsx Root layout
├── router.tsx Hash-mode routes
├── index.css
├── lib/utils.ts Tailwind cn() helper
├── components/ui/ shadcn components (button, card, dialog, input, …)
├── pages/
│ ├── home.tsx Workflow list
│ ├── detail.tsx Workflow detail view
│ └── editor.tsx Full editor page
└── editor/ Core graph editor
├── flow.tsx FlowEditor component
├── context.tsx State (useSyncExternalStore + Immer)
├── injection.ts DI container
├── type.ts Internal editor types
├── model/ Node/edge state model
├── nodes/ Start, role, end node components
├── edges/ Conditional edge rendering
├── panel/ Toolbar, add/edit panels
├── trans/ YAML ↔ graph conversion (trans-in, trans-out, validate)
├── layout/ Auto-layout
└── utils/ Event helpers, click-outside hook
```
## Configuration
| Setting | Default | Notes |
|---------|---------|-------|
| Dev server port | `3000` | Set in `server.ts` |
| Workflow storage (dev) | `tmp/workflow/` | YAML files during development |
| Path alias | `@/``src/` | Configured in `vite.config.ts` |
No library API — this package is an application, not importable as a module.
+2 -2
View File
@@ -6,8 +6,8 @@
<title>Workflow UI</title>
<link rel="stylesheet" href="./src/index.css" />
<script>
(() => {
const t = localStorage.getItem("theme");
(function () {
var t = localStorage.getItem("theme");
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.classList.add("dark");
}
+3
View File
@@ -7,3 +7,6 @@ const server = await createServer({
});
await server.listen();
// biome-ignore lint/nursery/noConsole: CLI user-facing output
console.log(`Workflow UI running at http://localhost:${PORT}`);
+3 -3
View File
@@ -1,11 +1,11 @@
import { Elysia, t } from "elysia";
import type { WorkFlowSteps } from "../shared/types.ts";
import {
createWorkflow,
deleteWorkflow,
getWorkflow,
listWorkflows,
getWorkflow,
createWorkflow,
saveWorkflow,
deleteWorkflow,
} from "./workflow.ts";
export function createApi() {
@@ -1,7 +1,11 @@
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
import { readdir, readFile, writeFile, unlink, mkdir } from "node:fs/promises";
import { join } from "node:path";
import type { RoleDefinition, Transition, WorkflowPayload } from "@uncaged/workflow-protocol";
import YAML from "yaml";
import type {
WorkflowPayload,
RoleDefinition,
Transition,
} from "@uncaged/workflow-protocol";
import type { WorkFlowSteps, WorkFlowTransition, WorkflowSummary } from "../shared/types.ts";
const WORKFLOW_DIR = join(import.meta.dirname, "..", "tmp", "workflow");
@@ -63,7 +67,7 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
let condName: string | null = null;
if (t.condition) {
if (expressionToName.has(t.condition)) {
condName = expressionToName.get(t.condition) ?? null;
condName = expressionToName.get(t.condition)!;
} else {
condName = `cond${condIdx++}`;
expressionToName.set(t.condition, condName);
@@ -86,7 +90,7 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
if (steps.length > 0) {
const firstRole = steps[0].role.name;
graph.$START = [
graph["$START"] = [
{
role: firstRole,
condition: null,
@@ -1,7 +1,7 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
@@ -37,8 +37,8 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
},
);
}
)
function Button({
className,
@@ -52,7 +52,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
)
}
export { Button, buttonVariants };
export { Button, buttonVariants }
@@ -1,6 +1,6 @@
import type * as React from "react";
import * as React from "react"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
function Card({
className,
@@ -13,11 +13,11 @@ function Card({
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className,
className
)}
{...props}
/>
);
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -26,11 +26,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className,
className
)}
{...props}
/>
);
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -39,11 +39,11 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className,
className
)}
{...props}
/>
);
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@@ -53,17 +53,20 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
);
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -73,7 +76,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
);
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -82,11 +85,19 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className,
className
)}
{...props}
/>
);
)
}
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -1,36 +1,40 @@
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { XIcon } from "lucide-react";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
className
)}
{...props}
/>
);
)
}
function DialogContent({
@@ -39,7 +43,7 @@ function DialogContent({
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean;
showCloseButton?: boolean
}) {
return (
<DialogPortal>
@@ -48,7 +52,7 @@ function DialogContent({
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
className
)}
{...props}
>
@@ -56,21 +60,32 @@ function DialogContent({
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon />
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />
);
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
@@ -79,46 +94,54 @@ function DialogFooter({
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className,
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
);
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("font-heading text-base leading-none font-medium", className)}
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
);
)
}
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
className
)}
{...props}
/>
);
)
}
export {
@@ -132,4 +155,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
};
}
@@ -1,7 +1,7 @@
import { Input as InputPrimitive } from "@base-ui/react/input";
import type * as React from "react";
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
@@ -10,11 +10,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
className
)}
{...props}
/>
);
)
}
export { Input };
export { Input }
@@ -1,19 +1,18 @@
import type * as React from "react";
import * as React from "react"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
// biome-ignore lint/a11y/noLabelWithoutControl: generic Label component; control association handled by consumer
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
className
)}
{...props}
/>
);
)
}
export { Label };
export { Label }
@@ -1,21 +1,25 @@
"use client";
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) {
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className,
className
)}
{...props}
/>
);
)
}
export { Separator };
export { Separator }
@@ -1,6 +1,6 @@
import type * as React from "react";
import * as React from "react"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
@@ -8,11 +8,11 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
className
)}
{...props}
/>
);
)
}
export { Textarea };
export { Textarea }
@@ -1,12 +1,12 @@
import { type ReactFlowInstance, useReactFlow } from "@xyflow/react";
import type { FC, PropsWithChildren } from "react";
import { createContext, useContext, useLayoutEffect, useMemo, useSyncExternalStore } from "react";
import type { AnyWorkNode } from "./type";
import { createContext, useMemo, useSyncExternalStore, useContext, useLayoutEffect } from 'react';
import type { FC, PropsWithChildren } from 'react';
import { useReactFlow, ReactFlowInstance } from '@xyflow/react';
import type { AnyWorkNode } from './type';
type Reduce<T> = (data: T) => T;
type Setter<T> = (ch: Reduce<T> | T) => void;
interface State<T, A> {
interface State<T, A> {
readonly get: () => T;
readonly set: Setter<T>;
readonly use: () => T;
@@ -15,7 +15,6 @@ interface State<T, A> {
readonly onlyView: boolean;
}
type Use = <T, A>(sub: SubModel<T, A>) => [T, A];
// biome-ignore lint/suspicious/noExplicitAny: UseV intentionally erases the action type
type UseV = <T>(sub: SubModel<T, any>) => T;
type Create<T, A> = (set: Setter<T>, get: () => T, model: Model) => A;
@@ -25,12 +24,10 @@ export function generate<T>(val: T) {
const listener = new Set<VoidFunction>();
const get = () => val;
function set(ch: T | ((prev: T) => T)) {
const next = typeof ch === "function" ? (ch as (prev: T) => T)(val) : ch;
const next = (typeof ch === 'function') ? (ch as (prev: T) => T)(val) : ch;
if (Object.is(val, next)) return;
val = next;
for (const call of listener) {
call();
}
listener.forEach(call => call());
}
const listen = (call: VoidFunction) => {
listener.add(call);
@@ -41,26 +38,21 @@ export function generate<T>(val: T) {
}
class SubModel<T, A> {
public readonly name: string;
private readonly make: () => T;
private readonly create: Create<T, A>;
private readonly onlyView: boolean;
constructor(name: string, _make: () => T, _create: Create<T, A>, _onlyView = false) {
this.name = name;
this.make = _make;
this.create = _create;
this.onlyView = _onlyView;
}
constructor(
public readonly name: string,
private make: () => T,
private create: Create<T, A>,
private onlyView = false,
) {}
public gen(model: Model): State<T, A> {
const { get, set, use, listen } = generate(this.make());
const actions = this.create(set, get, model);
return { get, set, use, listen, actions, onlyView: this.onlyView };
const { make, create, onlyView } = this;
const { get, set, use, listen } = generate(make());
const actions = create(set, get, model);
return { get, set, use, listen, actions, onlyView };
}
use(): [T, A] {
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
const { query } = useContext(Context);
const { use, actions } = query(this);
return [use(), actions];
@@ -75,27 +67,20 @@ class SubModel<T, A> {
}
}
// biome-ignore lint/suspicious/noExplicitAny: snapshot data is heterogeneous
type Snapshot = [name: string, data: any];
class Model {
private ustack: Snapshot[][] = [];
private rstack: Snapshot[][] = [];
private transaction = 0;
// biome-ignore lint/suspicious/noExplicitAny: backup stores heterogeneous state values
private backup = new Map<string, any>();
public flow = {} as ReactFlowInstance<AnyWorkNode>;
private stackListeners = new Set<() => void>();
public readonly stackState: readonly [boolean, boolean] = [false, false];
// biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
private readonly store: Map<string, State<any, any>>;
public readonly use: Use;
// biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
constructor(store: Map<string, State<any, any>>, use: Use) {
this.store = store;
this.use = use;
}
constructor(
private readonly store: Map<string, State<any, any>>,
public readonly use: Use,
) {}
public reset() {
this.ustack = [];
@@ -108,14 +93,12 @@ class Model {
public readonly listenStackState = (cb: () => void) => {
this.stackListeners.add(cb);
return () => this.stackListeners.delete(cb);
};
}
private triggerStackState() {
// @ts-expect-error
this.stackState = [this.canUndo(), this.canRedo()];
for (const call of this.stackListeners) {
call();
}
this.stackListeners.forEach(call => call());
}
private getStackState = () => this.stackState;
@@ -125,11 +108,13 @@ class Model {
}
public log() {
// biome-ignore lint/suspicious/noExplicitAny: debug log accumulates heterogeneous values
console.log('undo stack:', this.ustack);
console.log('redo stack:', this.rstack);
const snapshots: Record<string, any> = {};
for (const [name, state] of this.store) {
this.store.forEach((state, name) => {
snapshots[name] = state.get();
}
});
console.log('current state:', snapshots);
}
public undo() {
@@ -137,13 +122,11 @@ class Model {
const item = ustack.pop();
if (!item) return;
const step: Snapshot[] = [];
for (const [name, data] of item) {
const entry = store.get(name);
if (!entry) continue;
const { get, set } = entry;
item.forEach(([name, data]) => {
const { get, set } = store.get(name)!;
step.push([name, get()]);
set(data);
}
});
rstack.push(step);
this.triggerStackState();
}
@@ -153,13 +136,11 @@ class Model {
const item = rstack.pop();
if (!item) return;
const step: Snapshot[] = [];
for (const [name, data] of item) {
const entry = store.get(name);
if (!entry) continue;
const { get, set } = entry;
item.forEach(([name, data]) => {
const { get, set } = store.get(name)!;
step.push([name, get()]);
set(data);
}
});
ustack.push(step);
this.triggerStackState();
}
@@ -175,10 +156,10 @@ class Model {
public startTransaction() {
if (this.transaction === 0) {
this.backup.clear();
for (const [name, state] of this.store) {
if (state.onlyView) continue;
this.store.forEach((state, name) => {
if (state.onlyView) return;
this.backup.set(name, state.get());
}
});
}
this.transaction += 1;
return this.endTransaction;
@@ -189,26 +170,24 @@ class Model {
this.transaction -= 1;
if (this.transaction === 0) {
const changes: Snapshot[] = [];
for (const [name, state] of this.store) {
if (state.onlyView) continue;
this.store.forEach((state, name) => {
if (state.onlyView) return;
const before = this.backup.get(name);
if (Object.is(before, state.get())) continue;
if (Object.is(before, state.get())) return;
changes.push([name, before]);
}
});
this.backup.clear();
if (changes.length === 0) return;
this.ustack.push(changes);
this.rstack.length = 0;
this.triggerStackState();
}
};
}
}
function build() {
// biome-ignore lint/suspicious/noExplicitAny: store holds heterogeneous state types
const store = new Map<string, State<any, any>>();
// biome-ignore lint/suspicious/noExplicitAny: memo cache stores heterogeneous values
const mem: Record<string, any> = {};
function use<T, A>(m: SubModel<T, A>): [T, A] {
const state = query(m);
@@ -216,8 +195,8 @@ function build() {
}
const model = new Model(store, use);
if (process.env.NODE_ENV === "development") {
// @ts-expect-error
if (process.env.NODE_ENV === 'development') {
// @ts-ignore
window.__md__ = model;
}
@@ -227,9 +206,9 @@ function build() {
const created = m.gen(model);
store.set(m.name, created);
return created;
}
};
return { query, model, mem, use };
return { query, model, mem, use }
}
const Context = createContext(build());
@@ -243,28 +222,24 @@ export function RegisterFlowToContext() {
const instance = useReactFlow<AnyWorkNode>();
useLayoutEffect(() => {
model.flow = instance;
}, [instance, model]);
}, [instance]);
return null;
}
export const ModelProvider: FC<PropsWithChildren> = (p) => (
<Context.Provider value={useMemo(build, [])}>{p.children}</Context.Provider>
<Context.Provider value={useMemo(build, [])}>
{p.children}
</Context.Provider>
);
function defineModel<T, A>(name: string, make: () => T, create: Create<T, A>) {
return new SubModel<T, A>(name, make, create);
}
// biome-ignore lint/suspicious/noExplicitAny: default create returns setter directly
const defaultCreate: Create<any, Setter<any>> = (set) => set;
function defineView<T, A>(name: string, make: () => T, create: Create<T, A>): SubModel<T, A>;
function defineView<T>(name: string, make: () => T): SubModel<T, Setter<T>>;
function defineView<T>(
name: string,
make: () => T,
create?: Create<T, unknown>,
): SubModel<T, unknown> {
// biome-ignore lint/suspicious/noExplicitAny: wraps into SubModel with erased action type
function defineView<T, A>(name: string, make: () => T, create: Create<T, A>): SubModel<T, A>
function defineView<T>(name: string, make: () => T): SubModel<T, Setter<T>>
function defineView<T>(name: string, make: () => T, create?: any): any {
return new SubModel<T, any>(name, make, create ?? defaultCreate, true);
}
@@ -272,12 +247,9 @@ function memoize<T>(init: (use: Use, model: Model) => T) {
const id = uuid();
return {
use(): T {
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
const { mem, model, use } = useContext(Context);
if (!mem[id]) {
mem[id] = init(use, model);
}
return mem[id] as T;
const fn = mem[id] || (mem[id] = init(use, model));
return fn as T;
},
};
}
@@ -286,29 +258,21 @@ function compute<T>(calc: (use: UseV) => T) {
const id = uuid();
return {
use(): T {
// biome-ignore lint/correctness/useHookAtTopLevel: use() is called as a hook by consumers
const { mem, query } = useContext(Context);
let state: ReturnType<typeof generate<T>> = mem[id];
if (state) return state.use();
// biome-ignore lint/suspicious/noExplicitAny: deps collect heterogeneous SubModels
const deps = new Set<SubModel<any, any>>();
// biome-ignore lint/suspicious/noExplicitAny: useV erases action type
let usev = (m: SubModel<any, any>) => {
deps.add(m);
return query(m).get();
};
let usev = (m: SubModel<any, any>) => (deps.add(m), query(m).get());
mem[id] = state = generate<T>(calc(usev));
if (deps.size) {
usev = (m) => query(m).get();
usev = m => query(m).get();
const update = () => state.set(calc(usev));
for (const m of deps) {
query(m).listen(update);
}
deps.forEach(m => query(m).listen(update));
}
return state.use();
},
};
}
}
export const define = {
@@ -1,15 +1,15 @@
import {
type Edge,
EdgeLabelRenderer,
type EdgeProps,
getSmoothStepPath,
EdgeLabelRenderer,
useReactFlow,
type EdgeProps,
type Edge,
} from "@xyflow/react";
import { useState, useRef, useEffect, useMemo, type ReactNode } from "react";
import { Check } from "lucide-react";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "../../lib/utils.ts";
import { useModel } from "../context.tsx";
import type { ConditionalEdge as ConditionalEdgeType } from "../type.ts";
import { useModel } from "../context.tsx";
import { cn } from "../../lib/utils.ts";
const SOURCE_COLOR = "#10b981";
const TARGET_COLOR = "#3b82f6";
@@ -38,7 +38,7 @@ function GradientPath({
const gradientId = `gradient-${id}`;
const showLack = hasCondition === false;
const strokeStyle = selected
? { stroke: "#f59e0b", strokeWidth: 2 }
? { stroke: '#f59e0b', strokeWidth: 2 }
: { stroke: `url(#${gradientId})`, strokeWidth: 1.5 };
return (
@@ -63,7 +63,13 @@ function GradientPath({
strokeWidth={20}
className="react-flow__edge-interaction"
/>
<path id={id} d={path} fill="none" className="react-flow__edge-path" style={strokeStyle} />
<path
id={id}
d={path}
fill="none"
className="react-flow__edge-path"
style={strokeStyle}
/>
</>
);
}
@@ -137,12 +143,13 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
}}
onPointerDown={(e) => e.stopPropagation()}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: click handler on badge label */}
<div onClick={handleBadgeClick} onKeyDown={undefined} className="cursor-pointer">
<span
className={cn(
"inline-block px-1 bg-white rounded text-[10px]",
condition ? "border border-gray-300 text-black" : "border border-dashed text-red-500",
condition
? "border border-gray-300 text-black"
: "border border-dashed text-red-500",
)}
style={condition ? undefined : { borderColor: LACK_COLOR }}
>
@@ -159,6 +166,7 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
/>
<button
type="button"
@@ -175,7 +183,7 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
}
export function isElseEdge(edgeId: string, source: string, allEdges: Edge[]): boolean {
const siblings = allEdges.filter((e) => e.source === source && e.type === "conditional");
const siblings = allEdges.filter(e => e.source === source && e.type === 'conditional');
return siblings.length >= 2 && siblings[0].id === edgeId;
}
@@ -192,13 +200,7 @@ export function ConditionalEdge({
data,
}: EdgeProps<ConditionalEdgeType>): ReactNode {
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: RADIUS,
sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, borderRadius: RADIUS,
});
const flow = useReactFlow();
const model = useModel();
@@ -222,20 +224,14 @@ export function ConditionalEdge({
sourceY={sourceY}
targetX={targetX}
targetY={targetY}
hasCondition={isElse ? null : !!condition}
hasCondition={isElse ? null : (condition ? true : false)}
selected={!!selected}
/>
<EdgeLabelRenderer>
{isElse ? (
<ElseBadge labelX={labelX} labelY={labelY} />
) : (
<ConditionLabel
condition={condition}
labelX={labelX}
labelY={labelY}
onSave={handleSave}
/>
)}
{isElse
? <ElseBadge labelX={labelX} labelY={labelY} />
: <ConditionLabel condition={condition} labelX={labelX} labelY={labelY} onSave={handleSave} />
}
</EdgeLabelRenderer>
</>
);
@@ -252,13 +248,7 @@ export function GradientEdge({
selected,
}: EdgeProps<Edge>): ReactNode {
const [edgePath] = getSmoothStepPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
borderRadius: RADIUS,
sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, borderRadius: RADIUS,
});
return (
@@ -1,4 +1,4 @@
import { ConditionalEdge, GradientEdge } from "./conditional";
import { ConditionalEdge, GradientEdge } from './conditional';
export const edgeTypes = {
conditional: ConditionalEdge,
+20 -20
View File
@@ -1,16 +1,16 @@
import { Background, Controls, type Edge, ReactFlow, ReactFlowProvider } from "@xyflow/react";
import { createContext, createElement, memo, useContext, useEffect, useLayoutEffect } from "react";
// @ts-expect-error
import "@xyflow/react/dist/style.css";
import { ModelProvider, RegisterFlowToContext } from "./context";
import { edgeTypes } from "./edges";
import { FlowModel, InternalField } from "./injection";
import { edgesModel, handlers, injection, nodesModel } from "./model";
import { nodeTypes } from "./nodes";
import { Dialogs, TopCenterPanel } from "./panel";
import type { AnyWorkNode } from "./type";
import { memo, createElement, useLayoutEffect, useEffect, createContext, useContext } from 'react';
import { ReactFlow, ReactFlowProvider, Controls, Background, type Edge } from '@xyflow/react';
// @ts-ignore
import '@xyflow/react/dist/style.css';
import { nodesModel, edgesModel, handlers, injection } from './model';
import { ModelProvider, RegisterFlowToContext } from './context';
import { nodeTypes } from './nodes';
import { edgeTypes } from './edges';
import { Dialogs, TopCenterPanel } from './panel';
import type { AnyWorkNode } from './type';
import { FlowModel, InternalField } from './injection';
export * from "./trans/type";
export * from './trans/type';
const proOptions = { hideAttribution: true };
@@ -20,13 +20,11 @@ export const useReadonly = () => useContext(ReadonlyContext);
function Flow() {
const [nodes, { onNodesChange }] = nodesModel.use();
const [edges, { onEdgesChange, onConnect }] = edgesModel.use();
const { onNodeDragStart, onNodeDragStop, onConnectEnd, onBeforeDelete, onDelete, handleKeyDown } =
handlers.use();
const { onNodeDragStart, onNodeDragStop, onConnectEnd, onBeforeDelete, onDelete, handleKeyDown } = handlers.use();
const readonly = useReadonly();
return (
// biome-ignore lint/a11y/noStaticElementInteractions: keyboard handler for flow shortcuts
<div style={{ height: "100%" }} onKeyDown={readonly ? undefined : handleKeyDown}>
<div style={{ height: '100%' }} tabIndex={0} onKeyDown={readonly ? undefined : handleKeyDown}>
<ReactFlowProvider>
<ReactFlow<AnyWorkNode, Edge>
nodes={nodes}
@@ -72,11 +70,11 @@ function Connect({ model }: { model: FlowModel }) {
useLayoutEffect(() => {
return inject(instance);
}, [instance, inject]);
}, [instance]);
useEffect(() => {
return instance.on("load", loadSteps);
}, [instance, loadSteps]);
return instance.on('load', loadSteps);
}, [instance]);
return <MemoFlow />;
}
@@ -85,6 +83,8 @@ export { FlowModel };
// biome-ignore lint/style/noDefaultExport: FlowEditor is the main public component
export default ({ model, readonly = false }: Props) => (
<ReadonlyContext.Provider value={readonly}>
<ModelProvider>{createElement(Connect, { model })}</ModelProvider>
<ModelProvider>
{createElement(Connect, { model })}
</ModelProvider>
</ReadonlyContext.Provider>
);
@@ -1,5 +1,5 @@
import type { WorkFlowSteps } from "./trans";
import { Eventer } from "./utils/eventer";
import { WorkFlowSteps } from "./trans";
import { Eventer } from './utils/eventer';
interface PublicEvents {
save: WorkFlowSteps;
@@ -9,21 +9,19 @@ interface PrivateEvents {
load: WorkFlowSteps;
}
export const InternalField = Symbol("InternalField");
export const InternalField = Symbol('InternalField');
export class Injection extends Eventer<PrivateEvents> {
public readonly emitPublic: Eventer<PublicEvents>["emit"];
private inital_steps: WorkFlowSteps | undefined;
constructor(emitPublic: Eventer<PublicEvents>["emit"], inital_steps?: WorkFlowSteps) {
constructor(
public readonly emitPublic: Eventer<PublicEvents>['emit'],
private inital_steps?: WorkFlowSteps,
) {
super();
this.emitPublic = emitPublic;
this.inital_steps = inital_steps;
}
public on: Eventer<PrivateEvents>["on"] = (type, lisenter) => {
const off = super.on(type, lisenter);
if (type === "load" && this.inital_steps) {
public on: Eventer<PrivateEvents>['on'] = (type, lisenter) => {
const off = super.on(type, lisenter);
if (type === 'load' && this.inital_steps) {
lisenter(this.inital_steps);
this.inital_steps = undefined;
}
@@ -39,10 +37,13 @@ export class FlowModel {
public readonly [InternalField]: Injection;
constructor(inital_steps?: WorkFlowSteps) {
this[InternalField] = new Injection(this.eventer.emit.bind(this.eventer), inital_steps);
this[InternalField] = new Injection(
this.eventer.emit.bind(this.eventer),
inital_steps,
);
}
public load(steps: WorkFlowSteps) {
this[InternalField].emit("load", steps);
this[InternalField].emit('load', steps);
}
}
@@ -1,4 +1,4 @@
import type { Edge, Node } from "@xyflow/react";
import { Node, Edge } from '@xyflow/react';
const DEFAULT_NODE_WIDTH = 120;
const DEFAULT_NODE_HEIGHT = 50;
@@ -34,8 +34,8 @@ function buildGraph(nodes: Node[], edges: Edge[]) {
// 构建图
for (const edge of edges) {
if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
outgoing.get(edge.source)?.push(edge.target);
incoming.get(edge.target)?.push(edge.source);
outgoing.get(edge.source)!.push(edge.target);
incoming.get(edge.target)!.push(edge.source);
inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1);
}
}
@@ -55,17 +55,17 @@ function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
const queue: string[] = [];
// 1. start 节点固定在第 0 层
layers.set("start", 0);
queue.push("start");
layers.set('start', 0);
queue.push('start');
// 2. BFS 分层(排除 end 节点,稍后单独处理)
while (queue.length > 0) {
const current = queue.shift() ?? "";
const currentLayer = layers.get(current) ?? 0;
const current = queue.shift()!;
const currentLayer = layers.get(current)!;
for (const target of outgoing.get(current) ?? []) {
// 跳过 end 节点,稍后处理
if (target === "end") continue;
if (target === 'end') continue;
const newLayer = currentLayer + 1;
const existingLayer = layers.get(target);
@@ -93,7 +93,7 @@ function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
// 把它们放在中间层
const middleLayer = Math.max(1, Math.floor((maxLayer + 1) / 2));
for (const node of nodes) {
if (node.id !== "start" && node.id !== "end" && !layers.has(node.id)) {
if (node.id !== 'start' && node.id !== 'end' && !layers.has(node.id)) {
layers.set(node.id, middleLayer);
}
}
@@ -101,13 +101,13 @@ function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
// 5. 重新计算最大层级(可能因为孤立节点而变化)
maxLayer = 0;
for (const [id, layer] of layers) {
if (id !== "end") {
if (id !== 'end') {
maxLayer = Math.max(maxLayer, layer);
}
}
// 6. end 节点固定在最后一层
layers.set("end", maxLayer + 1);
layers.set('end', maxLayer + 1);
return layers;
}
@@ -123,7 +123,7 @@ function groupByLayer<N extends Node>(nodes: N[], layers: Map<string, number>):
if (!groups.has(layer)) {
groups.set(layer, []);
}
groups.get(layer)?.push(node);
groups.get(layer)!.push(node);
}
return groups;
@@ -152,7 +152,7 @@ function calculateLayerWidths(layerGroups: Map<number, Node[]>): Map<number, num
*/
function calculateLayerXPositions(
layerWidths: Map<number, number>,
maxLayer: number,
maxLayer: number
): Map<number, number> {
const xPositions = new Map<number, number>();
let currentX = 0;
@@ -1,13 +1,13 @@
import type { Edge } from "@xyflow/react";
import { define } from "../context";
import type { AnyWorkNode, RoleNodeData } from "../type";
import { edgesModel } from "./edges";
import { nodesModel } from "./nodes";
import type { Edge } from '@xyflow/react';
import { define } from '../context';
import { nodesModel } from './nodes';
import { edgesModel } from './edges';
import type { RoleNodeData, AnyWorkNode } from '../type';
type ConnectHandle = {
id?: string | null;
nodeId: string;
type: "source" | "target";
type: 'source' | 'target';
};
export type AddNodeState = {
@@ -21,10 +21,10 @@ type CommitParams = {
};
function addNodeView() {
return null as AddNodeState | null;
return null as (AddNodeState | null);
}
export const addNodeViewModel = define.view("addNodeView", addNodeView, (set, get, model) => {
export const addNodeViewModel = define.view('addNodeView', addNodeView, (set, get, model) => {
function start(state: AddNodeState) {
set(state);
}
@@ -42,19 +42,12 @@ export const addNodeViewModel = define.view("addNodeView", addNodeView, (set, ge
const { data } = params;
const id = `n${Date.now()}`;
const node = {
id,
data,
position,
type: "role" as const,
origin: [0.0, 0.5] as [number, number],
};
const node = { id, data, position, type: 'role' as const, origin: [0.0, 0.5] as [number, number] };
const [fnid, fhid] = [fromNode.id, fromHandle.id];
const newEdge: Edge =
fromHandle.type === "source"
? { id: `e${fnid}-${id}`, source: fnid, target: id, sourceHandle: fhid, animated: true }
: { id: `e${id}-${fnid}`, source: id, target: fnid, targetHandle: fhid, animated: true };
const newEdge: Edge = fromHandle.type === 'source'
? { id: `e${fnid}-${id}`, source: fnid, target: id, sourceHandle: fhid, animated: true }
: { id: `e${id}-${fnid}`, source: id, target: fnid, targetHandle: fhid, animated: true };
model.startTransaction();
model.use(nodesModel)[1].set((nds) => nds.concat(node));
@@ -1,16 +1,21 @@
import { applyEdgeChanges, type Connection, type Edge, type EdgeChange } from "@xyflow/react";
import { define } from "../context";
import {
applyEdgeChanges,
type Edge,
type EdgeChange,
type Connection,
} from '@xyflow/react';
import { define } from '../context';
function makeEdges(): Edge[] {
return [];
}
function isInputHandle(handle: string | null | undefined): boolean {
return handle === "input" || handle === "input-top" || handle === "input-bottom";
return handle === 'input' || handle === 'input-top' || handle === 'input-bottom';
}
function isOutputHandle(handle: string | null | undefined): boolean {
return handle === "output" || handle === "output-top" || handle === "output-bottom";
return handle === 'output' || handle === 'output-top' || handle === 'output-bottom';
}
function normalizeConnection(params: Edge | Connection): Edge | Connection {
@@ -28,10 +33,10 @@ function normalizeConnection(params: Edge | Connection): Edge | Connection {
let edgeCounter = 0;
export const edgesModel = define.model("edges", makeEdges, (set, get, model) => {
export const edgesModel = define.model('edges', makeEdges, (set, get, model) => {
function onEdgesChange(changes: EdgeChange[]) {
const whites = new Set(["add", "replace"]);
if (changes.some((c) => whites.has(c.type))) {
const whites = new Set(['add', 'replace']);
if (changes.some(c => whites.has(c.type))) {
model.startTransaction();
set((eds) => applyEdgeChanges(changes, eds));
requestAnimationFrame(model.endTransaction);
@@ -49,7 +54,7 @@ export const edgesModel = define.model("edges", makeEdges, (set, get, model) =>
const currentEdges = get();
const duplicate = currentEdges.some(
(e) => e.source === normalized.source && e.target === normalized.target,
e => e.source === normalized.source && e.target === normalized.target,
);
if (duplicate) return;
@@ -62,15 +67,15 @@ export const edgesModel = define.model("edges", makeEdges, (set, get, model) =>
animated: true,
} as Edge;
const existingFromSource = currentEdges.filter((e) => e.source === normalized.source);
const existingFromSource = currentEdges.filter(e => e.source === normalized.source);
if (existingFromSource.length > 0) {
edge.type = "conditional";
edge.data = { condition: "" };
edge.type = 'conditional';
edge.data = { condition: '' };
const promoted = currentEdges.map((e) => {
if (e.source === normalized.source && e.type !== "conditional") {
return { ...e, type: "conditional" as const, data: { condition: "" } };
const promoted = currentEdges.map(e => {
if (e.source === normalized.source && e.type !== 'conditional') {
return { ...e, type: 'conditional' as const, data: { condition: '' } };
}
return e;
});
@@ -1,21 +1,21 @@
import { define } from "../context";
import type { RoleNodeData, WorkNode } from "../type";
import { nodesModel } from "./nodes";
import { define } from '../context';
import { nodesModel } from './nodes';
import type { RoleNodeData, WorkNode } from '../type';
export type EditNodeState = {
node: WorkNode<"role">;
node: WorkNode<'role'>;
};
function editNodeView() {
return null as EditNodeState | null;
return null as (EditNodeState | null);
}
export const editNodeViewModel = define.view("editNodeView", editNodeView, (set, get, model) => {
export const editNodeViewModel = define.view('editNodeView', editNodeView, (set, get, model) => {
function start(nodeId: string) {
const [nodes] = model.use(nodesModel);
const node = nodes.find((n) => n.id === nodeId);
if (!node || node.type !== "role") return;
set({ node: node as WorkNode<"role"> });
const node = nodes.find(n => n.id === nodeId);
if (!node || node.type !== 'role') return;
set({ node: node as WorkNode<'role'> });
}
function cancel() {
@@ -31,7 +31,6 @@ export const editNodeViewModel = define.view("editNodeView", editNodeView, (set,
model.startTransaction();
editNode(state.node.id, (node) => {
// biome-ignore lint/suspicious/noExplicitAny: node data type varies by node kind
node.data = data as any;
});
requestAnimationFrame(model.endTransaction);
@@ -1,14 +1,14 @@
import type { OnBeforeDelete, OnConnectEnd, OnDelete, OnNodeDrag } from "@xyflow/react";
import { define } from "../context";
import { LayoutLR } from "../layout";
import type { WorkFlowSteps } from "../trans";
import { transIn, transOut, validate } from "../trans";
import type { AnyWorkNode } from "../type";
import { addNodeViewModel } from "./add-node-view";
import { edgesModel } from "./edges";
import { editNodeViewModel } from "./edit-node-view";
import { injection } from "./inject";
import { nodesModel } from "./nodes";
import type { OnNodeDrag, OnConnectEnd, OnBeforeDelete, OnDelete } from '@xyflow/react';
import { define } from '../context';
import { addNodeViewModel } from './add-node-view';
import type { AnyWorkNode } from '../type';
import { LayoutLR } from '../layout';
import { nodesModel } from './nodes';
import { edgesModel } from './edges';
import { injection } from './inject';
import { transIn, transOut, validate } from '../trans';
import type { WorkFlowSteps } from '../trans';
import { editNodeViewModel } from './edit-node-view';
export const handlers = define.memoize((use, model) => {
const onNodeDragStart: OnNodeDrag<AnyWorkNode> = () => {
@@ -23,7 +23,6 @@ export const handlers = define.memoize((use, model) => {
if (!to || !fromHandle || !fromNode) return;
const { clientX, clientY } = event as MouseEvent;
use(addNodeViewModel)[1].start({
// biome-ignore lint/suspicious/noExplicitAny: ReactFlow node type mismatch
fromNode: fromNode as any as AnyWorkNode,
fromHandle: fromHandle,
position: model.flow.screenToFlowPosition({ x: clientX, y: clientY }),
@@ -32,17 +31,15 @@ export const handlers = define.memoize((use, model) => {
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes, edges }) => {
for (const node of nodes) {
if (node.type === "start" || node.type === "end") {
if (node.type === 'start' || node.type === 'end') {
return false;
}
}
if (edges.length > 0) {
const allEdges = use(edgesModel)[0];
for (const edge of edges) {
if (edge.type !== "conditional") continue;
const siblings = allEdges.filter(
(e) => e.source === edge.source && e.type === "conditional",
);
if (edge.type !== 'conditional') continue;
const siblings = allEdges.filter(e => e.source === edge.source && e.type === 'conditional');
if (siblings.length >= 2 && siblings[0].id === edge.id) {
return false;
}
@@ -55,20 +52,20 @@ export const handlers = define.memoize((use, model) => {
if (deletedEdges.length > 0) {
const currentEdges = use(edgesModel)[0];
const sourcesToCheck = new Set(
deletedEdges.filter((e) => e.type === "conditional").map((e) => e.source),
deletedEdges
.filter(e => e.type === 'conditional')
.map(e => e.source),
);
if (sourcesToCheck.size > 0) {
let needsDowngrade = false;
const updatedEdges = currentEdges.map((e) => {
if (!sourcesToCheck.has(e.source) || e.type !== "conditional") return e;
const siblings = currentEdges.filter(
(s) => s.source === e.source && s.type === "conditional",
);
const updatedEdges = currentEdges.map(e => {
if (!sourcesToCheck.has(e.source) || e.type !== 'conditional') return e;
const siblings = currentEdges.filter(s => s.source === e.source && s.type === 'conditional');
if (siblings.length === 1) {
needsDowngrade = true;
const { data: _, ...rest } = e;
return { ...rest, type: "default" as const };
return { ...rest, type: 'default' as const };
}
return e;
});
@@ -97,7 +94,7 @@ export const handlers = define.memoize((use, model) => {
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.code === "Escape") {
if (event.code === 'Escape') {
const [addView, addViewActions] = use(addNodeViewModel);
const [editView, editViewActions] = use(editNodeViewModel);
if (addView) addViewActions.cancel();
@@ -105,12 +102,12 @@ export const handlers = define.memoize((use, model) => {
return;
}
if (event.code === "KeyZ") {
if (event.code === 'KeyZ') {
if (event.ctrlKey || event.metaKey) {
if (event.shiftKey) model.redo();
else model.undo();
}
} else if (event.code === "KeyY") {
} else if (event.code === 'KeyY') {
if (event.ctrlKey || event.metaKey) {
model.redo();
}
@@ -133,7 +130,7 @@ export const handlers = define.memoize((use, model) => {
if (result.valid) {
const steps = transOut(nodes, edges);
const instance = use(injection)[0];
instance.emitPublic("save", steps);
instance.emitPublic('save', steps);
}
return result;
}
@@ -1,6 +1,6 @@
export { type AddNodeState, addNodeViewModel } from "./add-node-view";
export { edgesModel } from "./edges";
export { type EditNodeState, editNodeViewModel } from "./edit-node-view";
export { handlers } from "./handlers";
export { injection } from "./inject";
export { nodesModel } from "./nodes";
export { nodesModel } from './nodes';
export { edgesModel } from './edges';
export { addNodeViewModel, type AddNodeState } from './add-node-view';
export { editNodeViewModel, type EditNodeState } from './edit-node-view';
export { handlers } from './handlers';
export { injection } from './inject';
@@ -3,7 +3,8 @@
*/
import { define } from "../context.tsx";
import { Injection } from "../injection.ts";
import { Injection } from '../injection.ts';
const NOOP = () => {};
const placeholder = new Injection(NOOP);
@@ -12,7 +13,7 @@ function make(): Injection {
return placeholder;
}
export const injection = define.view("injection", make, (set) => {
export const injection = define.view('injection', make, (set) => {
function reset() {
set(make());
}
@@ -1,49 +1,48 @@
import { applyNodeChanges, type NodeChange } from "@xyflow/react";
import { type Draft, produce } from "immer";
import { define } from "../context";
import type { AnyWorkNode } from "../type";
import { produce, type Draft } from 'immer';
import { applyNodeChanges, NodeChange } from '@xyflow/react';
import { define } from '../context';
import type { AnyWorkNode } from '../type';
function makeNodes(): AnyWorkNode[] {
return [
{
id: "start",
type: "start",
data: { label: "Start" },
id: 'start',
type: 'start',
data: { label: 'Start' },
position: { x: 0, y: 0 },
},
{
id: "end",
data: { label: "End" },
id: 'end',
data: { label: 'End' },
position: { x: 1000, y: 0 },
type: "end",
type: 'end',
},
];
}
export const nodesModel = define.model("nodes", makeNodes, (set, _get, model) => {
const whites = new Set<NodeChange["type"]>(["add", "replace"]);
export const nodesModel = define.model('nodes', makeNodes, (set, _get, model) => {
const whites = new Set<NodeChange['type']>(['add', 'replace']);
function onNodesChange(changes: NodeChange<AnyWorkNode>[]) {
if (changes.some((c) => whites.has(c.type))) {
if (changes.some(c => whites.has(c.type))) {
model.startTransaction();
set((nds) => applyNodeChanges(changes, nds));
requestAnimationFrame(model.endTransaction);
return;
}
set((nds) => applyNodeChanges(changes, nds));
}
};
function editNode(id: string, updater: (node: Draft<AnyWorkNode>) => void) {
set(
produce((draft) => {
const node = draft.find((n) => n.id === id);
if (node) updater(node);
}),
);
set(produce((draft) => {
const node = draft.find(n => n.id === id);
if (node) updater(node);
}));
}
function deleteNode(id: string) {
model.startTransaction();
set((nds) => nds.filter((n) => n.id !== id));
set((nds) => nds.filter(n => n.id !== id));
requestAnimationFrame(model.endTransaction);
}
@@ -1,19 +1,23 @@
import { Handle, type Node, type NodeProps, Position } from "@xyflow/react";
import { EndNode } from "./nodes.style";
import { Handle, Position, Node, NodeProps } from '@xyflow/react';
import { EndNode } from './nodes.style';
interface NodeData {
label: string;
[key: string]: unknown;
}
type NodeType = Node<NodeData, "end">;
type NodeType = Node<NodeData, 'end'>;
type Props = NodeProps<NodeType>;
export function NodeEnd({ data }: Props) {
return (
<EndNode>
<Handle type="target" position={Position.Left} id="input" />
{data?.label || "End"}
<Handle
type="target"
position={Position.Left}
id="input"
/>
{data?.label || 'End'}
</EndNode>
);
}
@@ -1,6 +1,6 @@
import { NodeEnd } from "./end";
import { NodeRole } from "./role";
import { NodeStart } from "./start";
import { NodeStart } from './start';
import { NodeEnd } from './end';
import { NodeRole } from './role';
export const nodeTypes = {
start: NodeStart,
@@ -1,5 +1,5 @@
import { Pencil, Trash2 } from "lucide-react";
import type { ReactNode } from "react";
import { Pencil, Trash2 } from "lucide-react";
import { Button } from "../../components/ui/button.tsx";
type Props = {
@@ -13,13 +13,7 @@ export function NodeToolbarActions({ onEdit, onDelete }: Props): ReactNode {
<Button variant="ghost" size="icon-xs" onClick={onEdit} title="编辑">
<Pencil />
</Button>
<Button
variant="ghost"
size="icon-xs"
className="hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
title="删除"
>
<Button variant="ghost" size="icon-xs" className="hover:bg-destructive/10 hover:text-destructive" onClick={onDelete} title="删除">
<Trash2 />
</Button>
</div>
@@ -45,7 +45,12 @@ export function NodeContent({ children }: { children: ReactNode }): ReactNode {
export function NodeIcon({ className, children }: Props): ReactNode {
return (
<div className={cn("flex items-center justify-center w-8 h-8 rounded-lg shrink-0", className)}>
<div
className={cn(
"flex items-center justify-center w-8 h-8 rounded-lg shrink-0",
className,
)}
>
{children}
</div>
);
@@ -57,14 +62,23 @@ export function NodeBody({ children }: { children: ReactNode }): ReactNode {
export function NodeKindLabel({ className, children }: Props): ReactNode {
return (
<div className={cn("text-[10px] font-semibold uppercase tracking-wide mb-1", className)}>
<div
className={cn(
"text-[10px] font-semibold uppercase tracking-wide mb-1",
className,
)}
>
{children}
</div>
);
}
export function NodeHint({ children }: { children: ReactNode }): ReactNode {
return <div className="text-[13px] text-gray-800 leading-snug break-words">{children}</div>;
return (
<div className="text-[13px] text-gray-800 leading-snug break-words">
{children}
</div>
);
}
export function NodeSubHint({ children }: { children: ReactNode }): ReactNode {
@@ -79,6 +93,8 @@ export function RoleIcon({ children }: { children: ReactNode }): ReactNode {
);
}
export function RoleKindLabel({ children }: { children: ReactNode }): ReactNode {
export function RoleKindLabel({
children,
}: { children: ReactNode }): ReactNode {
return <NodeKindLabel className="text-teal-700">{children}</NodeKindLabel>;
}
@@ -1,20 +1,24 @@
import { Handle, type NodeProps, NodeToolbar, Position, useNodeConnections } from "@xyflow/react";
import { Users } from "lucide-react";
import { useMemo } from "react";
import { useReadonly } from "../flow";
import { nodesModel } from "../model";
import { editNodeViewModel } from "../model/edit-node-view";
import type { WorkNode } from "../type";
import { NodeToolbarActions } from "./node-toolbar";
import { NodeBody, NodeContent, NodeHint, RoleIcon, RoleKindLabel } from "./nodes.style";
import { Handle, Position, NodeToolbar, useNodeConnections, type NodeProps } from '@xyflow/react';
import { Users } from 'lucide-react';
import {
NodeContent,
NodeBody,
RoleIcon,
RoleKindLabel,
NodeHint,
} from './nodes.style';
import { NodeToolbarActions } from './node-toolbar';
import { editNodeViewModel } from '../model/edit-node-view';
import { nodesModel } from '../model';
import type { WorkNode } from '../type';
import { useMemo, type ReactNode } from 'react';
import { useReadonly } from '../flow';
type Props = NodeProps<WorkNode<"role">>;
type Props = NodeProps<WorkNode<'role'>>;
const containerClass =
"bg-white border border-gray-200 rounded-[10px] shadow-sm transition-all duration-200 hover:shadow-md hover:border-gray-400 [&_.react-flow\\_\\_handle]:w-3 [&_.react-flow\\_\\_handle]:h-3 [&_.react-flow\\_\\_handle]:border-2 [&_.react-flow\\_\\_handle]:transition-all [&_.react-flow\\_\\_handle]:duration-150";
const containerClass = "bg-white border border-gray-200 rounded-[10px] shadow-sm transition-all duration-200 hover:shadow-md hover:border-gray-400 [&_.react-flow\\_\\_handle]:w-3 [&_.react-flow\\_\\_handle]:h-3 [&_.react-flow\\_\\_handle]:border-2 [&_.react-flow\\_\\_handle]:transition-all [&_.react-flow\\_\\_handle]:duration-150";
const targetClass = "!bg-blue-100 !border-blue-500 hover:!bg-blue-500 hover:!border-blue-600";
const sourceClass =
"!bg-emerald-100 !border-emerald-500 hover:!bg-emerald-500 hover:!border-emerald-600";
const sourceClass = "!bg-emerald-100 !border-emerald-500 hover:!bg-emerald-500 hover:!border-emerald-600";
export function NodeRole({ data, id, selected }: Props) {
const startEdit = editNodeViewModel.useCreation().start;
@@ -31,14 +35,8 @@ export function NodeRole({ data, id, selected }: Props) {
return set;
}, [connections, id]);
const hasInputConnection =
connectedHandles.has("input") ||
connectedHandles.has("input-top") ||
connectedHandles.has("input-bottom");
const hasOutputConnection =
connectedHandles.has("output") ||
connectedHandles.has("output-top") ||
connectedHandles.has("output-bottom");
const hasInputConnection = connectedHandles.has('input') || connectedHandles.has('input-top') || connectedHandles.has('input-bottom');
const hasOutputConnection = connectedHandles.has('output') || connectedHandles.has('output-top') || connectedHandles.has('output-bottom');
const showHandle = (handleId: string, alwaysShow: boolean) => {
if (readonly) return connectedHandles.has(handleId);
@@ -47,35 +45,9 @@ export function NodeRole({ data, id, selected }: Props) {
return (
<div className={containerClass}>
{showHandle("input", true) && (
<Handle
type="target"
position={Position.Left}
id="input"
className={targetClass}
isConnectableStart
/>
)}
{showHandle("input-top", hasInputConnection) && (
<Handle
type="target"
position={Position.Top}
id="input-top"
style={{ left: "30%" }}
className={targetClass}
isConnectableStart
/>
)}
{showHandle("input-bottom", hasInputConnection) && (
<Handle
type="target"
position={Position.Bottom}
id="input-bottom"
style={{ left: "30%" }}
className={targetClass}
isConnectableStart
/>
)}
{showHandle('input', true) && <Handle type="target" position={Position.Left} id="input" className={targetClass} isConnectableStart />}
{showHandle('input-top', hasInputConnection) && <Handle type="target" position={Position.Top} id="input-top" style={{ left: '30%' }} className={targetClass} isConnectableStart />}
{showHandle('input-bottom', hasInputConnection) && <Handle type="target" position={Position.Bottom} id="input-bottom" style={{ left: '30%' }} className={targetClass} isConnectableStart />}
<NodeContent>
<RoleIcon>
<Users size={16} />
@@ -86,37 +58,14 @@ export function NodeRole({ data, id, selected }: Props) {
</NodeBody>
</NodeContent>
<NodeToolbar isVisible={selected && !readonly} position={Position.Bottom}>
<NodeToolbarActions onEdit={() => startEdit(id)} onDelete={() => deleteNode(id)} />
<NodeToolbarActions
onEdit={() => startEdit(id)}
onDelete={() => deleteNode(id)}
/>
</NodeToolbar>
{showHandle("output", true) && (
<Handle
type="source"
position={Position.Right}
id="output"
className={sourceClass}
isConnectableEnd
/>
)}
{showHandle("output-top", hasOutputConnection) && (
<Handle
type="source"
position={Position.Top}
id="output-top"
style={{ left: "70%" }}
className={sourceClass}
isConnectableEnd
/>
)}
{showHandle("output-bottom", hasOutputConnection) && (
<Handle
type="source"
position={Position.Bottom}
id="output-bottom"
style={{ left: "70%" }}
className={sourceClass}
isConnectableEnd
/>
)}
{showHandle('output', true) && <Handle type="source" position={Position.Right} id="output" className={sourceClass} isConnectableEnd />}
{showHandle('output-top', hasOutputConnection) && <Handle type="source" position={Position.Top} id="output-top" style={{ left: '70%' }} className={sourceClass} isConnectableEnd />}
{showHandle('output-bottom', hasOutputConnection) && <Handle type="source" position={Position.Bottom} id="output-bottom" style={{ left: '70%' }} className={sourceClass} isConnectableEnd />}
</div>
);
}
@@ -1,13 +1,13 @@
import { Handle, type Node, type NodeProps, Position, useNodeConnections } from "@xyflow/react";
import { useMemo } from "react";
import { StartNode } from "./nodes.style";
import { Handle, Position, Node, NodeProps, useNodeConnections } from '@xyflow/react';
import { StartNode } from './nodes.style';
import { useMemo } from 'react';
interface NodeData {
label: string;
[key: string]: unknown;
}
type NodeType = Node<NodeData, "start">;
type NodeType = Node<NodeData, 'start'>;
type Props = NodeProps<NodeType>;
export function NodeStart({ data, id }: Props) {
@@ -19,7 +19,7 @@ export function NodeStart({ data, id }: Props) {
return (
<StartNode>
{data?.label || "Start"}
{data?.label || 'Start'}
<Handle
type="source"
position={Position.Right}
@@ -1,16 +1,16 @@
import { type ReactNode, useEffect, useState } from "react";
import { Button } from "../../components/ui/button.tsx";
import { useState, useEffect, type ReactNode } from "react";
import { addNodeViewModel, type AddNodeState } from "../model/index.ts";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogFooter,
} from "../../components/ui/dialog.tsx";
import { Input } from "../../components/ui/input.tsx";
import { Label } from "../../components/ui/label.tsx";
import { Textarea } from "../../components/ui/textarea.tsx";
import { type AddNodeState, addNodeViewModel } from "../model/index.ts";
import { Button } from "../../components/ui/button.tsx";
import { Label } from "../../components/ui/label.tsx";
import type { RoleNodeData } from "../type.ts";
type FormProps = {
@@ -34,7 +34,7 @@ function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
setPrepare("");
setExecute("");
setReport("");
}, []);
}, [state]);
function handleConfirm() {
if (!name.trim()) return;
@@ -59,7 +59,11 @@ function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
<div className="flex flex-col gap-3 max-h-[400px] overflow-y-auto p-1">
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> *</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="角色名称" />
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="角色名称"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"></Label>
@@ -132,9 +136,7 @@ export function AddNodeDialog(): ReactNode {
return (
<Dialog
open={state !== null}
onOpenChange={(open) => {
if (!open) cancel();
}}
onOpenChange={(open) => { if (!open) cancel(); }}
>
<DialogContent showCloseButton={false} className="sm:max-w-md">
{state && <Form state={state} onSubmit={commit} onCancel={cancel} />}
@@ -1,16 +1,19 @@
import { type ReactNode, useEffect, useState } from "react";
import { Button } from "../../components/ui/button.tsx";
import { useState, useEffect, type ReactNode } from "react";
import {
editNodeViewModel,
type EditNodeState,
} from "../model/edit-node-view.ts";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogFooter,
} from "../../components/ui/dialog.tsx";
import { Input } from "../../components/ui/input.tsx";
import { Label } from "../../components/ui/label.tsx";
import { Textarea } from "../../components/ui/textarea.tsx";
import { type EditNodeState, editNodeViewModel } from "../model/edit-node-view.ts";
import { Button } from "../../components/ui/button.tsx";
import { Label } from "../../components/ui/label.tsx";
import type { RoleNodeData } from "../type.ts";
type FormProps = {
@@ -58,7 +61,11 @@ function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
<div className="flex flex-col gap-3 max-h-[400px] overflow-y-auto p-1">
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"> *</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="角色名称" />
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="角色名称"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground"></Label>
@@ -131,9 +138,7 @@ export function EditNodeDialog(): ReactNode {
return (
<Dialog
open={state !== null}
onOpenChange={(open) => {
if (!open) cancel();
}}
onOpenChange={(open) => { if (!open) cancel(); }}
>
<DialogContent showCloseButton={false} className="sm:max-w-md">
{state && <Form state={state} onSubmit={commit} onCancel={cancel} />}
@@ -1,7 +1,8 @@
import { Panel } from "@xyflow/react";
import { AddNodeDialog } from "./add-node";
import { EditNodeDialog } from "./edit-node";
import { Toolbar } from "./toolbar";
import { Panel } from '@xyflow/react';
import { AddNodeDialog } from './add-node';
import { EditNodeDialog } from './edit-node';
import { Toolbar } from './toolbar';
export function Dialogs() {
return (
@@ -12,6 +13,7 @@ export function Dialogs() {
);
}
export function TopCenterPanel() {
return (
<Panel position="top-center">
@@ -1,21 +1,28 @@
import { type ReactNode } from "react";
import {
Undo2,
Redo2,
Users,
LayoutList,
Save,
} from "lucide-react";
import { useReactFlow, useStoreApi } from "@xyflow/react";
import { LayoutList, Redo2, Save, Undo2, Users } from "lucide-react";
import { type ReactNode, useState } from "react";
import { Button } from "../../components/ui/button.tsx";
import { Separator } from "../../components/ui/separator.tsx";
import { cn } from "../../lib/utils.ts";
import { useModel } from "../context.tsx";
import { handlers, nodesModel } from "../model/index.ts";
import { Separator } from "../../components/ui/separator.tsx";
import { Button } from "../../components/ui/button.tsx";
import type { RoleNodeData, WorkNode } from "../type.ts";
import { uuid } from "../utils/index.ts";
import { useState } from "react";
import { cn } from "../../lib/utils.ts";
const DEFAULT_ROLE_DATA: RoleNodeData = {
name: "新角色",
description: "",
identity: "",
prepare: "",
execute: "",
report: "",
name: '新角色',
description: '',
identity: '',
prepare: '',
execute: '',
report: '',
};
export function Toolbar(): ReactNode {
@@ -41,9 +48,9 @@ export function Toolbar(): ReactNode {
const centerY = (height / 2 - y) / zoom;
const id = `n${uuid()}`;
const node: WorkNode<"role"> = {
const node: WorkNode<'role'> = {
id,
type: "role",
type: 'role',
position: { x: centerX - 80, y: centerY - 40 },
data: { ...DEFAULT_ROLE_DATA },
};
@@ -56,22 +63,10 @@ export function Toolbar(): ReactNode {
return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-white rounded-[10px] shadow-md">
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon-sm"
title="撤销 (Undo)"
onClick={handleUndo}
disabled={!canUndo}
>
<Button variant="ghost" size="icon-sm" title="撤销 (Undo)" onClick={handleUndo} disabled={!canUndo}>
<Undo2 />
</Button>
<Button
variant="ghost"
size="icon-sm"
title="重做 (Redo)"
onClick={handleRedo}
disabled={!canRedo}
>
<Button variant="ghost" size="icon-sm" title="重做 (Redo)" onClick={handleRedo} disabled={!canRedo}>
<Redo2 />
</Button>
</div>
@@ -106,12 +101,14 @@ function SaveButton(): ReactNode {
if (valid) {
setToast({ open: true, severity: "success", message: "流程保存成功" });
} else {
const errorMessages = errors.map(({ message, nodeId }) => (
<div key={nodeId ?? message}>
{nodeId ? `节点 ${nodeId}` : ""}
{message}
</div>
));
const errorMessages = errors.map(
({ message, nodeId }) => (
<div key={nodeId ?? message}>
{nodeId ? `节点 ${nodeId}` : ""}
{message}
</div>
),
);
setToast({
open: true,
severity: "error",
@@ -1,4 +1,4 @@
export * from "./trans-in";
export * from "./trans-out";
export * from "./type";
export * from "./validate";
export * from './type';
export * from './trans-in';
export * from './trans-out';
export * from './validate';
@@ -1,20 +1,20 @@
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
import { uuid } from "../utils";
import type { WorkFlowStep } from "./type";
import type { AnyWorkNode, AnyWorkEdge, ConditionalEdge } from '../type';
import type { WorkFlowStep } from './type';
import { uuid } from '../utils';
type Result = {
nodes: AnyWorkNode[];
edges: AnyWorkEdge[];
};
const _OUT_HANDLES = ["output-top", "output", "output-bottom"] as const;
const IN_HANDLES = ["input-top", "input", "input-bottom"] as const;
const OUT_HANDLES = ['output-top', 'output', 'output-bottom'] as const;
const IN_HANDLES = ['input-top', 'input', 'input-bottom'] as const;
function assignHandles(
indices: number[],
edges: AnyWorkEdge[],
handles: readonly string[],
key: "sourceHandle" | "targetHandle",
key: 'sourceHandle' | 'targetHandle',
): void {
if (indices.length === 1) {
edges[indices[0]] = { ...edges[indices[0]], [key]: handles[1] };
@@ -29,18 +29,8 @@ function assignHandles(
}
export function transIn(steps: WorkFlowStep[]): Result {
const startNode: AnyWorkNode = {
id: "start",
type: "start",
data: { label: "Start" },
position: { x: 0, y: 0 },
};
const endNode: AnyWorkNode = {
id: "end",
type: "end",
data: { label: "End" },
position: { x: 250, y: 0 },
};
const startNode: AnyWorkNode = { id: 'start', type: 'start', data: { label: 'Start' }, position: { x: 0, y: 0 } };
const endNode: AnyWorkNode = { id: 'end', type: 'end', data: { label: 'End' }, position: { x: 250, y: 0 } };
if (steps.length === 0) {
return { nodes: [startNode, endNode], edges: [] };
@@ -50,9 +40,9 @@ export function transIn(steps: WorkFlowStep[]): Result {
const edges: AnyWorkEdge[] = [];
const nameToId = new Map<string, string>();
const idToOrder = new Map<string, number>();
nameToId.set("END", "end");
idToOrder.set("start", -1);
idToOrder.set("end", steps.length);
nameToId.set('END', 'end');
idToOrder.set('start', -1);
idToOrder.set('end', steps.length);
for (let si = 0; si < steps.length; si++) {
const step = steps[si];
@@ -61,25 +51,25 @@ export function transIn(steps: WorkFlowStep[]): Result {
idToOrder.set(nodeId, si);
nodes.push({
id: nodeId,
type: "role",
type: 'role',
data: { ...step.role },
position: { x: 0, y: 0 },
});
}
const firstStepId = nameToId.get(steps[0].role.name) ?? "";
const firstStepId = nameToId.get(steps[0].role.name)!;
edges.push({
id: `e-start-${firstStepId}`,
source: "start",
sourceHandle: "output",
source: 'start',
sourceHandle: 'output',
target: firstStepId,
targetHandle: "input",
targetHandle: 'input',
animated: true,
});
for (const step of steps) {
const sourceId = nameToId.get(step.role.name) ?? "";
const _sourceOrder = idToOrder.get(sourceId) ?? 0;
const sourceId = nameToId.get(step.role.name)!;
const sourceOrder = idToOrder.get(sourceId)!;
const hasMultipleTransitions = step.transitions.length > 1;
const sorted = hasMultipleTransitions
@@ -105,10 +95,10 @@ export function transIn(steps: WorkFlowStep[]): Result {
id: edgeId,
source: sourceId,
target: targetId,
sourceHandle: "output",
targetHandle: "input",
type: "conditional",
data: { condition: t.condition ?? "" },
sourceHandle: 'output',
targetHandle: 'input',
type: 'conditional',
data: { condition: t.condition ?? '' },
animated: true,
};
if (hasMultipleTransitions && i === 0) {
@@ -121,8 +111,8 @@ export function transIn(steps: WorkFlowStep[]): Result {
id: edgeId,
source: sourceId,
target: targetId,
sourceHandle: "output",
targetHandle: "input",
sourceHandle: 'output',
targetHandle: 'input',
animated: true,
});
}
@@ -130,7 +120,7 @@ export function transIn(steps: WorkFlowStep[]): Result {
// out: else → output (right); if → sort by target order desc (rightmost first), then top/bottom
for (const e of elseEdges) {
edges.push({ ...e, sourceHandle: "output" });
edges.push({ ...e, sourceHandle: 'output' });
}
if (ifEdges.length > 0) {
const sortedIf = [...ifEdges].sort((a, b) => {
@@ -138,7 +128,7 @@ export function transIn(steps: WorkFlowStep[]): Result {
const ob = idToOrder.get(b.target) ?? 0;
return ob - oa;
});
const ifHandles = ["output-top", "output-bottom"] as const;
const ifHandles = ['output-top', 'output-bottom'] as const;
for (let i = 0; i < sortedIf.length; i++) {
edges.push({ ...sortedIf[i], sourceHandle: ifHandles[i % ifHandles.length] });
}
@@ -150,7 +140,7 @@ export function transIn(steps: WorkFlowStep[]): Result {
for (let i = 0; i < edges.length; i++) {
const target = edges[i].target;
if (!incomingByTarget.has(target)) incomingByTarget.set(target, []);
incomingByTarget.get(target)?.push(i);
incomingByTarget.get(target)!.push(i);
}
for (const indices of incomingByTarget.values()) {
@@ -159,7 +149,7 @@ export function transIn(steps: WorkFlowStep[]): Result {
const ob = idToOrder.get(edges[b].source) ?? 0;
return oa - ob;
});
assignHandles(indices, edges, IN_HANDLES, "targetHandle");
assignHandles(indices, edges, IN_HANDLES, 'targetHandle');
}
return { nodes, edges };
@@ -1,5 +1,5 @@
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge, WorkNode } from "../type";
import type { WorkFlowStep, WorkFlowTransition } from "./type";
import type { AnyWorkNode, AnyWorkEdge, WorkNode, ConditionalEdge } from '../type';
import type { WorkFlowStep, WorkFlowTransition } from './type';
export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowStep[] {
const nodeMap = new Map<string, AnyWorkNode>();
@@ -12,10 +12,10 @@ export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowSt
if (!outgoingEdges.has(edge.source)) {
outgoingEdges.set(edge.source, []);
}
outgoingEdges.get(edge.source)?.push(edge);
outgoingEdges.get(edge.source)!.push(edge);
}
const startOutEdges = outgoingEdges.get("start") ?? [];
const startOutEdges = outgoingEdges.get('start') ?? [];
if (startOutEdges.length === 0) return [];
const firstNodeId = startOutEdges[0].target;
@@ -34,26 +34,23 @@ function traverse(
visited: Set<string>,
steps: WorkFlowStep[],
): void {
if (visited.has(nodeId) || nodeId === "start" || nodeId === "end") return;
if (visited.has(nodeId) || nodeId === 'start' || nodeId === 'end') return;
visited.add(nodeId);
const node = nodeMap.get(nodeId);
if (!node || node.type !== "role") return;
if (!node || node.type !== 'role') return;
const roleNode = node as WorkNode<"role">;
const roleNode = node as WorkNode<'role'>;
const outEdges = outgoingEdges.get(nodeId) ?? [];
const transitions: WorkFlowTransition[] = outEdges.map((edge, index) => {
const targetNode = nodeMap.get(edge.target);
const target =
edge.target === "end"
? "END"
: targetNode?.type === "role"
? (targetNode as WorkNode<"role">).data.name
: edge.target;
const target = edge.target === 'end'
? 'END'
: (targetNode?.type === 'role' ? (targetNode as WorkNode<'role'>).data.name : edge.target);
let condition: string | null = null;
if (edge.type === "conditional") {
if (edge.type === 'conditional') {
const isElse = outEdges.length >= 2 && index === 0;
condition = isElse ? null : ((edge as ConditionalEdge).data?.condition ?? null);
}
@@ -1,6 +1,6 @@
export type {
WorkFlowRole,
WorkFlowTransition,
WorkFlowStep,
WorkFlowSteps,
WorkFlowTransition,
} from "../../../shared/types.ts";
@@ -1,4 +1,4 @@
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
import type { AnyWorkNode, AnyWorkEdge, ConditionalEdge } from '../type';
export type ValidationError = {
nodeId: string | null;
@@ -13,12 +13,12 @@ export type ValidationResult = {
export function validate(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): ValidationResult {
const errors: ValidationError[] = [];
const outgoing = buildEdgeMap(edges, "source");
const incoming = buildEdgeMap(edges, "target");
const outgoing = buildEdgeMap(edges, 'source');
const incoming = buildEdgeMap(edges, 'target');
const startNodes = nodes.filter((n) => n.type === "start");
const endNodes = nodes.filter((n) => n.type === "end");
const roleNodes = nodes.filter((n) => n.type === "role");
const startNodes = nodes.filter(n => n.type === 'start');
const endNodes = nodes.filter(n => n.type === 'end');
const roleNodes = nodes.filter(n => n.type === 'role');
validateStartNode(startNodes, outgoing, errors);
validateEndNode(endNodes, incoming, outgoing, errors);
@@ -29,14 +29,17 @@ export function validate(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): Validation
return { valid: errors.length === 0, errors };
}
function buildEdgeMap(edges: AnyWorkEdge[], key: "source" | "target"): Map<string, AnyWorkEdge[]> {
function buildEdgeMap(
edges: AnyWorkEdge[],
key: 'source' | 'target',
): Map<string, AnyWorkEdge[]> {
const map = new Map<string, AnyWorkEdge[]>();
for (const edge of edges) {
const id = edge[key];
if (!map.has(id)) {
map.set(id, []);
}
map.get(id)?.push(edge);
map.get(id)!.push(edge);
}
return map;
}
@@ -47,20 +50,20 @@ function validateStartNode(
errors: ValidationError[],
): void {
if (startNodes.length === 0) {
errors.push({ nodeId: null, message: "缺少 Start 节点" });
errors.push({ nodeId: null, message: '缺少 Start 节点' });
return;
}
if (startNodes.length > 1) {
errors.push({ nodeId: null, message: "Start 节点只能有一个" });
errors.push({ nodeId: null, message: 'Start 节点只能有一个' });
return;
}
const startId = startNodes[0].id;
const outEdges = outgoing.get(startId) ?? [];
if (outEdges.length === 0) {
errors.push({ nodeId: startId, message: "Start 节点必须有一个输出连接" });
errors.push({ nodeId: startId, message: 'Start 节点必须有一个输出连接' });
} else if (outEdges.length > 1) {
errors.push({ nodeId: startId, message: "Start 节点只能有一个输出连接" });
errors.push({ nodeId: startId, message: 'Start 节点只能有一个输出连接' });
}
}
@@ -71,23 +74,23 @@ function validateEndNode(
errors: ValidationError[],
): void {
if (endNodes.length === 0) {
errors.push({ nodeId: null, message: "缺少 End 节点" });
errors.push({ nodeId: null, message: '缺少 End 节点' });
return;
}
if (endNodes.length > 1) {
errors.push({ nodeId: null, message: "End 节点只能有一个" });
errors.push({ nodeId: null, message: 'End 节点只能有一个' });
return;
}
const endId = endNodes[0].id;
const inEdges = incoming.get(endId) ?? [];
if (inEdges.length === 0) {
errors.push({ nodeId: endId, message: "End 节点必须有至少一个输入连接" });
errors.push({ nodeId: endId, message: 'End 节点必须有至少一个输入连接' });
}
const outEdges = outgoing.get(endId) ?? [];
if (outEdges.length > 0) {
errors.push({ nodeId: endId, message: "End 节点不能有输出连接" });
errors.push({ nodeId: endId, message: 'End 节点不能有输出连接' });
}
}
@@ -102,22 +105,22 @@ function validateRoleNodes(
const outEdges = outgoing.get(node.id) ?? [];
if (inEdges.length === 0) {
errors.push({ nodeId: node.id, message: "角色节点缺少输入连接" });
errors.push({ nodeId: node.id, message: '角色节点缺少输入连接' });
}
if (outEdges.length === 0) {
errors.push({ nodeId: node.id, message: "角色节点缺少输出连接" });
errors.push({ nodeId: node.id, message: '角色节点缺少输出连接' });
}
if (outEdges.length > 1) {
const conditionalEdges = outEdges.filter((e) => e.type === "conditional");
const conditionalEdges = outEdges.filter(e => e.type === 'conditional');
if (conditionalEdges.length !== outEdges.length) {
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带条件" });
errors.push({ nodeId: node.id, message: '多输出节点的所有出边必须附带条件' });
} else {
const ifEdges = conditionalEdges.slice(1);
for (const edge of ifEdges) {
const condEdge = edge as ConditionalEdge;
if (!condEdge.data?.condition?.trim()) {
errors.push({ nodeId: node.id, message: "条件边的条件表达式不能为空" });
errors.push({ nodeId: node.id, message: '条件边的条件表达式不能为空' });
break;
}
}
@@ -126,9 +129,12 @@ function validateRoleNodes(
}
}
function validateRoleCount(roleNodes: AnyWorkNode[], errors: ValidationError[]): void {
function validateRoleCount(
roleNodes: AnyWorkNode[],
errors: ValidationError[],
): void {
if (roleNodes.length < 2) {
errors.push({ nodeId: null, message: "工作流至少需要 2 个角色节点" });
errors.push({ nodeId: null, message: '工作流至少需要 2 个角色节点' });
}
}
@@ -145,21 +151,21 @@ function validateReachability(
const backwardAdj = new Map<string, string[]>();
for (const edge of edges) {
if (!forwardAdj.has(edge.source)) forwardAdj.set(edge.source, []);
forwardAdj.get(edge.source)?.push(edge.target);
forwardAdj.get(edge.source)!.push(edge.target);
if (!backwardAdj.has(edge.target)) backwardAdj.set(edge.target, []);
backwardAdj.get(edge.target)?.push(edge.source);
backwardAdj.get(edge.target)!.push(edge.source);
}
const reachableFromStart = bfs(startNodes[0].id, forwardAdj);
const reachableFromEnd = bfs(endNodes[0].id, backwardAdj);
for (const node of nodes) {
if (node.type === "start" || node.type === "end") continue;
if (node.type === 'start' || node.type === 'end') continue;
if (!reachableFromStart.has(node.id)) {
errors.push({ nodeId: node.id, message: "节点不可从 Start 到达(孤立节点)" });
errors.push({ nodeId: node.id, message: '节点不可从 Start 到达(孤立节点)' });
}
if (!reachableFromEnd.has(node.id)) {
errors.push({ nodeId: node.id, message: "节点无法到达 End(死端节点)" });
errors.push({ nodeId: node.id, message: '节点无法到达 End(死端节点)' });
}
}
}
@@ -169,7 +175,7 @@ function bfs(startId: string, adj: Map<string, string[]>): Set<string> {
const queue = [startId];
visited.add(startId);
while (queue.length > 0) {
const current = queue.shift() ?? "";
const current = queue.shift()!;
for (const next of adj.get(current) ?? []) {
if (!visited.has(next)) {
visited.add(next);
@@ -1,4 +1,4 @@
import type { Edge, Node } from "@xyflow/react";
import type { Node, Edge } from '@xyflow/react';
type AnyKeyBase = { [key: string]: unknown | undefined };
@@ -19,11 +19,11 @@ export type NodeMap = {
export type WorkNodeType = keyof NodeMap;
export type WorkNode<T extends WorkNodeType> = Node<NodeMap[T], T>;
export type AnyWorkNode = WorkNode<"start"> | WorkNode<"end"> | WorkNode<"role">;
export type AnyWorkNode = WorkNode<'start'> | WorkNode<'end'> | WorkNode<'role'>;
export type ConditionalEdgeData = AnyKeyBase & {
condition: string;
};
export type ConditionalEdge = Edge<ConditionalEdgeData, "conditional">;
export type ConditionalEdge = Edge<ConditionalEdgeData, 'conditional'>;
export type AnyWorkEdge = ConditionalEdge | Edge;
@@ -1,16 +1,12 @@
type Maper<T> = {
[key: string]: T;
};
interface Maper<T> { [key: string]: T }
type Listen<T> = (data: T) => void;
// biome-ignore lint/suspicious/noExplicitAny: generic event map requires any
export class Eventer<M extends Maper<any>> {
// biome-ignore lint/complexity/noBannedTypes: Set<Function> needed for heterogeneous listener types
private lisenters = {} as { [K in keyof M]: Set<Function> };
public on<K extends keyof M>(key: K, lisenter: Listen<M[K]>) {
let set = this.lisenters[key];
if (set === undefined) {
if (set == undefined) {
set = new Set();
this.lisenters[key] = set;
}
@@ -30,8 +26,6 @@ export class Eventer<M extends Maper<any>> {
const set = this.lisenters[key];
if (set === undefined) return;
// Todo: maybe implement stoping bubble
for (const call of set) {
call(data);
}
set.forEach(call => call(data));
}
}

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