Compare commits

..

1 Commits

Author SHA1 Message Date
xingyue 83c1eedc75 fix: add workspace path sandboxing and UWF_BUILTIN_ALLOW_SHELL check
- Add resolvePathInWorkspace() to reject paths escaping workspace root
- Apply sandboxing to read_file, write_file, and run_command tools
- Gate run_command behind UWF_BUILTIN_ALLOW_SHELL=1 env var
- Add tests for resolvePathInWorkspace escape detection
2026-05-23 16:12:01 +08:00
86 changed files with 843 additions and 2179 deletions
@@ -1,73 +0,0 @@
# Issue #418: ACP session/resume 返回空文本
## 调研日期: 2026-05-23
## 根因
`session/resume` 在 restore 路径下 `_make_agent()` 失败,异常被静默吞掉。
### 完整调用链
```
resume_session(sid)
→ update_cwd(sid)
→ get_session(sid) → _restore(sid)
→ _make_agent()
→ resolve_runtime_provider("custom") 失败(line 548-561)
→ AIAgent() 抛出 "No LLM provider configured"(line 564)
→ except Exception 静默吞掉(line 482-484)→ return None
→ return None
→ state is None → fallback: create_session()(新 sid,无历史)
```
### 关键代码位置(acp_adapter/session.py)
- `_restore()` line 426-498: 从 DB 恢复 session,但 except 太宽泛
- `_make_agent()` line 520-568: provider 解析在 restore 路径下不完整
- Line 548-561: `resolve_runtime_provider("custom")` 失败后,`base_url` 虽然从 DB 取到了但没传给 AIAgent
### 实测行为
1. Phase 1: `session/new` + `prompt` → 正常,有 `agent_message_chunk`
2. Phase 2: `session/resume` + `prompt`
- resume 返回成功,但 `available_commands_update` 里 sessionId 是新的(create_session fallback)
- 用原始 sid 发 prompt → `stopReason: "refusal"`(session 不在内存中)
- 用新 sid 发 prompt → 能跑但无历史(agent 回答"不知道 secret code")
### 验证脚本
```python
# 直接调用 _restore 验证
cd ~/.hermes/hermes-agent
python3 -c "
import sys; sys.path.insert(0, '.')
from acp_adapter.session import SessionManager
sm = SessionManager()
result = sm._restore('SESSION_ID_HERE')
print(result) # None — _make_agent 抛异常被吞掉
"
```
### 两个 bug
1. **`_make_agent` provider fallback 不完整**: restore 时 DB 里有 `base_url``api_mode`,但 `resolve_runtime_provider` 失败后这些值没被正确传递给 AIAgent
2. **`_restore` 的 except 太宽泛**: 静默吞掉所有异常,连 warning 都只在 debug 级别,导致 resume 失败完全无感知
### Hermes 版本
- v0.10.0 (2026.4.16) — 初始测试
- v0.14.0 (2026.5.16) — 更新后重新测试,bug 仍在
- 代码路径: ~/.hermes/hermes-agent/acp_adapter/session.py
### v0.14.0 测试结果 (2026-05-23)
- `_restore` 仍因 `custom` provider 解析失败返回 None
- 日志更清晰了:`WARNING: Failed to recreate agent for ACP session ...`
- resume fallback 创建新 session(新 sid),但 agent 居然能回答之前的问题(可能通过 memory/session search)
- 核心问题不变:sessionId 变了,client 用旧 sid 发 prompt → refusal
### 上游 Issue
- https://github.com/NousResearch/hermes-agent/issues/13489 — 已评论根因分析
- https://github.com/NousResearch/hermes-agent/issues/8083 — resume 静默创建新 session
- https://github.com/NousResearch/hermes-agent/issues/18452 — _make_agent fallback 不完整
-77
View File
@@ -1,77 +0,0 @@
name: "debate"
description: "Structured debate between two sides. Tests cross-process session resume."
roles:
against:
description: "Argues against the proposition"
goal: |
You are a skilled debater arguing AGAINST the proposition.
Be logical, cite evidence, and directly address your opponent's points.
Keep each argument concise (under 200 words).
capabilities:
- argumentation
- critical-thinking
procedure: |
1. If this is the opening, present your strongest argument against the proposition.
2. If responding to the other side, directly counter their points with evidence and logic.
3. If you find yourself genuinely convinced by the other side, you may concede.
output: |
Provide your argument in the frontmatter.
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
frontmatter:
type: object
properties:
argument:
type: string
conceded:
type: boolean
required: [argument, conceded]
for:
description: "Argues for the proposition"
goal: |
You are a skilled debater arguing FOR the proposition.
Be logical, cite evidence, and directly address your opponent's points.
Keep each argument concise (under 200 words).
capabilities:
- argumentation
- critical-thinking
procedure: |
1. Read the opposing side's latest argument carefully.
2. Counter their points with evidence and logic.
3. If you find yourself genuinely convinced by the other side, you may concede.
output: |
Provide your argument in the frontmatter.
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
frontmatter:
type: object
properties:
argument:
type: string
conceded:
type: boolean
required: [argument, conceded]
conditions:
againstConceded:
description: "The against side conceded"
expression: "$last('against').conceded = true"
forConceded:
description: "The for side conceded"
expression: "$last('for').conceded = true"
graph:
$START:
- role: "against"
condition: null
prompt: "Present your opening argument against the proposition."
against:
- role: "$END"
condition: "againstConceded"
prompt: "The against side conceded. Debate over."
- role: "for"
condition: null
prompt: "Counter the opposing argument. Address their points directly."
for:
- role: "$END"
condition: "forConceded"
prompt: "The for side conceded. Debate over."
- role: "against"
condition: null
prompt: "Counter the opposing argument. Address their points directly."
+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
@@ -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", () => {
@@ -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.
*/
+13 -114
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) {
@@ -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,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { resolve } from "node:path";
import { resolvePath } from "../src/tools/path.js";
import { resolvePath, resolvePathInWorkspace } from "../src/tools/path.js";
describe("resolvePath", () => {
test("resolves relative paths against cwd", () => {
@@ -19,3 +19,25 @@ describe("resolvePath", () => {
expect(resolved).toBe(resolve("/workspace/project", "../other/file.ts"));
});
});
describe("resolvePathInWorkspace", () => {
test("allows relative paths within workspace", () => {
const resolved = resolvePathInWorkspace("/workspace", "src/foo.ts");
expect(resolved).toBe(resolve("/workspace", "src/foo.ts"));
});
test("rejects path that escapes workspace root", () => {
const resolved = resolvePathInWorkspace("/workspace", "../etc/passwd");
expect(resolved).toBeNull();
});
test("rejects absolute path escape via double-dot", () => {
const resolved = resolvePathInWorkspace("/workspace/project", "../../outside");
expect(resolved).toBeNull();
});
test("allows deep nested path", () => {
const resolved = resolvePathInWorkspace("/workspace", "a/b/c/file.txt");
expect(resolved).toBe(resolve("/workspace", "a/b/c/file.txt"));
});
});
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
import type { AgentContext } from "@uncaged/workflow-agent-kit";
import { buildBuiltinMessages } from "../src/prompt.js";
import { buildBuiltinPrompt } from "../src/prompt.js";
function minimalContext(overrides: Partial<AgentContext> = {}): AgentContext {
return {
@@ -11,13 +11,11 @@ function minimalContext(overrides: Partial<AgentContext> = {}): AgentContext {
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",
procedure: ["Edit files"],
output: "A patch",
frontmatter: "schema-hash",
},
@@ -28,36 +26,22 @@ function minimalContext(overrides: Partial<AgentContext> = {}): AgentContext {
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");
}
describe("buildBuiltinPrompt", () => {
test("includes output format, task, and role goal", () => {
const prompt = buildBuiltinPrompt(minimalContext());
expect(prompt).toContain("status: done");
expect(prompt).toContain("## Goal");
expect(prompt).toContain("Ship the fix");
expect(prompt).toContain("## Task");
expect(prompt).toContain("Fix the bug");
});
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(
test("includes history when steps exist", () => {
const prompt = buildBuiltinPrompt(
minimalContext({
steps: [
{
@@ -65,172 +49,11 @@ describe("buildBuiltinMessages", () => {
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');
}
expect(prompt).toContain("## Previous Steps");
expect(prompt).toContain("planner");
});
});
+3 -2
View File
@@ -12,7 +12,7 @@ import { 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 { buildBuiltinPrompt } from "./prompt.js";
import type { BuiltinSessionState } from "./types.js";
const sessions = new Map<string, BuiltinSessionState>();
@@ -69,7 +69,8 @@ async function runBuiltin(ctx: AgentContext): Promise<AgentRunResult> {
const provider = resolveModel(config, config.defaultModel);
const sessionId = generateUlid(Date.now());
const messages = buildBuiltinMessages(ctx);
const systemPrompt = buildBuiltinPrompt(ctx);
const messages: ChatMessage[] = [{ role: "system", content: systemPrompt }];
const session: BuiltinSessionState = {
sessionId,
View File
+1 -1
View File
@@ -3,7 +3,7 @@ export { extractFinalAssistantText, 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 { buildBuiltinPrompt } from "./prompt.js";
export type { BuiltinTool, ToolContext } from "./tools/index.js";
export { executeBuiltinTool, getBuiltinTools } from "./tools/index.js";
export type {
+17 -80
View File
@@ -1,99 +1,36 @@
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) {
function buildHistorySummary(steps: AgentContext["steps"]): string {
if (steps.length === 0) {
return "";
}
const lines: string[] = ["## What Happened Since Your Last Turn"];
for (let i = fromIndex; i < toIndex; i++) {
const lines: string[] = ["## Previous Steps"];
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
if (step === undefined) {
continue;
}
lines.push("");
lines.push(formatStep(step, i + 1));
lines.push(`### Step ${i + 1}: ${step.role}`);
lines.push(`Output: ${JSON.stringify(step.output)}`);
lines.push(`Agent: ${step.agent}`);
}
return lines.join("\n");
}
function buildUserTurnContent(edgePrompt: string, summary: string): string {
/** Assemble output format, role prompt, task, and history (aligned with buildHermesPrompt). */
export function buildBuiltinPrompt(ctx: AgentContext): string {
const roleDef = ctx.workflow.roles[ctx.role];
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
const parts: string[] = [];
if (edgePrompt !== "") {
parts.push(edgePrompt);
if (ctx.outputFormatInstruction !== "") {
parts.push(ctx.outputFormatInstruction, "");
}
if (summary !== "") {
if (parts.length > 0) {
parts.push("");
}
parts.push(summary);
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
const historyBlock = buildHistorySummary(ctx.steps);
if (historyBlock !== "") {
parts.push("", historyBlock);
}
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);
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,6 +1,17 @@
import { resolve } from "node:path";
import { isAbsolute, relative, resolve } from "node:path";
/** Resolve a path relative to the working directory. */
export function resolvePath(cwd: string, inputPath: string): string {
return resolve(cwd, inputPath);
}
/** Reject paths that escape the workspace root via `..` segments. */
export function resolvePathInWorkspace(cwd: string, inputPath: string): string | null {
const root = resolve(cwd);
const target = resolve(root, inputPath);
const rel = relative(root, target);
if (rel.startsWith("..") || isAbsolute(rel)) {
return null;
}
return target;
}
@@ -1,5 +1,5 @@
import { readFile, stat } from "node:fs/promises";
import { resolvePath } from "./path.js";
import { resolvePathInWorkspace } from "./path.js";
import type { BuiltinTool } from "./types.js";
const MAX_READ_BYTES = 512 * 1024;
@@ -23,7 +23,10 @@ export const readFileTool: BuiltinTool = {
if (!isRecord(args) || typeof args.path !== "string") {
return "Error: path must be a string";
}
const resolved = resolvePath(ctx.cwd, args.path);
const resolved = resolvePathInWorkspace(ctx.cwd, args.path);
if (resolved === null) {
return "Error: path escapes workspace root";
}
try {
const info = await stat(resolved);
if (!info.isFile()) {
@@ -1,5 +1,5 @@
import { spawn } from "node:child_process";
import { resolvePath } from "./path.js";
import { resolvePathInWorkspace } from "./path.js";
import type { BuiltinTool } from "./types.js";
const COMMAND_TIMEOUT_MS = 60_000;
@@ -56,7 +56,8 @@ function runShell(
export const runCommandTool: BuiltinTool = {
name: "run_command",
description: "Run a shell command. Output is truncated to 32KB.",
description:
"Run a shell command in the workspace. Requires UWF_BUILTIN_ALLOW_SHELL=1. Output is truncated.",
parameters: {
type: "object",
required: ["command"],
@@ -70,6 +71,9 @@ export const runCommandTool: BuiltinTool = {
additionalProperties: false,
},
execute: async (args, ctx) => {
if (process.env.UWF_BUILTIN_ALLOW_SHELL !== "1") {
return "Error: run_command disabled. Set UWF_BUILTIN_ALLOW_SHELL=1 to enable.";
}
if (!isRecord(args) || typeof args.command !== "string") {
return "Error: command must be a string";
}
@@ -78,7 +82,11 @@ export const runCommandTool: BuiltinTool = {
if (typeof args.cwd !== "string") {
return "Error: cwd must be a string";
}
workDir = resolvePath(ctx.cwd, args.cwd);
const resolved = resolvePathInWorkspace(ctx.cwd, args.cwd);
if (resolved === null) {
return "Error: cwd escapes workspace root";
}
workDir = resolved;
}
try {
const { stdout, stderr, code } = await runShell(args.command, workDir);
@@ -1,6 +1,6 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { resolvePath } from "./path.js";
import { resolvePathInWorkspace } from "./path.js";
import type { BuiltinTool } from "./types.js";
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -23,7 +23,10 @@ export const writeFileTool: BuiltinTool = {
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);
const resolved = resolvePathInWorkspace(ctx.cwd, args.path);
if (resolved === null) {
return "Error: path escapes workspace root";
}
try {
await mkdir(dirname(resolved), { recursive: true });
await writeFile(resolved, args.content, "utf8");
@@ -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);
@@ -1,18 +1,14 @@
import { spawn } from "node:child_process";
import type { Store } from "@uncaged/json-cas";
import {
type AgentContext,
type AgentRunResult,
buildRolePrompt,
createAgent,
getCachedSessionId,
setCachedSessionId,
} 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;
@@ -49,7 +45,6 @@ export function buildClaudeCodePrompt(ctx: AgentContext): string {
if (historyBlock !== "") {
parts.push("", historyBlock);
}
parts.push("", "## Current Instruction", "", ctx.edgePrompt);
return parts.join("\n");
}
@@ -91,8 +86,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 +103,7 @@ function spawnClaudeResume(
"--resume",
sessionId,
"--output-format",
"stream-json",
"--verbose",
"json",
"--dangerously-skip-permissions",
"--max-turns",
String(CLAUDE_MAX_TURNS),
@@ -118,7 +111,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,43 +119,14 @@ 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);
if (cachedSessionId !== null) {
try {
const { stdout } = await spawnClaudeResume(cachedSessionId, fullPrompt);
const result = await processClaudeOutput(stdout, ctx.store);
if (result.sessionId !== undefined && 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,
);
}
}
}
const { stdout } = await spawnClaudeRun(fullPrompt);
const result = await processClaudeOutput(stdout, ctx.store);
if (result.sessionId !== undefined && result.sessionId !== "") {
await setCachedSessionId(ctx.threadId, ctx.role, result.sessionId);
}
return result;
return processClaudeOutput(stdout, ctx.store);
}
async function continueClaudeCode(
@@ -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;
};
@@ -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.",
}),
);
@@ -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 });
@@ -1,17 +1,70 @@
// Re-export session cache from the shared agent-kit package.
export { getCachedSessionId, setCachedSessionId } from "@uncaged/workflow-agent-kit";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { resolveStorageRoot } from "@uncaged/workflow-agent-kit";
import type { ThreadId } from "@uncaged/workflow-protocol";
type HermesSessionCache = Record<string, string>;
function getCachePath(): string {
return join(resolveStorageRoot(), "cache", "hermes-sessions.json");
}
function cacheKey(threadId: ThreadId, role: string): string {
return `${threadId}:${role}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function readCache(): Promise<HermesSessionCache> {
const path = getCachePath();
try {
const text = await readFile(path, "utf8");
const raw = JSON.parse(text) as unknown;
if (!isRecord(raw)) {
return {};
}
const cache: HermesSessionCache = {};
for (const [key, value] of Object.entries(raw)) {
if (typeof value === "string" && value !== "") {
cache[key] = value;
}
}
return cache;
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
return {};
}
throw e;
}
}
async function writeCache(cache: HermesSessionCache): Promise<void> {
const path = getCachePath();
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
}
export function isResumeDisabled(): boolean {
// Hermes ACP session/resume is broken: _restore fails for custom providers
// because resolve_runtime_provider("custom") throws and base_url/api_mode
// are lost in the fallback path. Resume silently creates a new session
// (different sessionId, no history), causing empty-text responses.
// See: https://github.com/NousResearch/hermes-agent/issues/13489
// Disable by default until upstream fixes the bug. Set UWF_HERMES_RESUME=1
// to opt back in.
const enableFlag = process.env.UWF_HERMES_RESUME;
if (enableFlag === "1" || enableFlag === "true") {
return false;
}
return true;
const flag = process.env.UWF_NO_RESUME;
return flag !== undefined && flag !== "";
}
export async function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null> {
const cache = await readCache();
const sessionId = cache[cacheKey(threadId, role)];
return sessionId ?? null;
}
export async function setCachedSessionId(
threadId: ThreadId,
role: string,
sessionId: string,
): Promise<void> {
const cache = await readCache();
cache[cacheKey(threadId, role)] = sessionId;
await writeCache(cache);
}
@@ -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
View File
@@ -12,7 +12,6 @@ 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 type {
AgentContext,
-3
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,
});
}
@@ -1,75 +0,0 @@
import { randomBytes } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { ThreadId } from "@uncaged/workflow-protocol";
import { resolveStorageRoot } from "./storage.js";
type SessionCache = Record<string, string>;
function getCachePath(): string {
return join(resolveStorageRoot(), "cache", "agent-sessions.json");
}
function cacheKey(threadId: ThreadId, role: string): string {
return `${threadId}:${role}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function readCache(): Promise<SessionCache> {
const path = getCachePath();
try {
const text = await readFile(path, "utf8");
const raw = JSON.parse(text) as unknown;
if (!isRecord(raw)) {
return {};
}
const cache: SessionCache = {};
for (const [key, value] of Object.entries(raw)) {
if (typeof value === "string" && value !== "") {
cache[key] = value;
}
}
return cache;
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
return {};
}
throw e;
}
}
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`);
await writeFile(tmpPath, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
await rename(tmpPath, path);
}
/** Read the cached session ID for a thread+role pair. */
export async function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null> {
const cache = await readCache();
const sessionId = cache[cacheKey(threadId, role)];
return sessionId ?? null;
}
/** Write the session ID for a thread+role pair into the cache. */
export async function setCachedSessionId(
threadId: ThreadId,
role: string,
sessionId: string,
): Promise<void> {
const cache = await readCache();
cache[cacheKey(threadId, role)] = sessionId;
await writeCache(cache);
}
+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");
@@ -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,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 Label({ className, ...props }: React.ComponentProps<"label">) {
return (
@@ -8,11 +8,11 @@ function Label({ className, ...props }: React.ComponentProps<"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;
@@ -24,10 +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;
listener.forEach((call) => call());
listener.forEach(call => call());
}
const listen = (call: VoidFunction) => {
listener.add(call);
@@ -40,9 +40,9 @@ export function generate<T>(val: T) {
class SubModel<T, A> {
constructor(
public readonly name: string,
_make: () => T,
_create: Create<T, A>,
_onlyView = false,
private make: () => T,
private create: Create<T, A>,
private onlyView = false,
) {}
public gen(model: Model): State<T, A> {
@@ -93,12 +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()];
this.stackListeners.forEach((call) => call());
this.stackListeners.forEach(call => call());
}
private getStackState = () => this.stackState;
@@ -108,10 +108,13 @@ class Model {
}
public log() {
console.log('undo stack:', this.ustack);
console.log('redo stack:', this.rstack);
const snapshots: Record<string, any> = {};
this.store.forEach((state, name) => {
snapshots[name] = state.get();
});
console.log('current state:', snapshots);
}
public undo() {
@@ -179,7 +182,7 @@ class Model {
this.rstack.length = 0;
this.triggerStackState();
}
};
}
}
function build() {
@@ -192,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;
}
@@ -203,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());
@@ -219,12 +222,14 @@ 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>) {
@@ -232,8 +237,8 @@ function defineModel<T, A>(name: string, make: () => T, create: Create<T, A>) {
}
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, 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);
}
@@ -261,13 +266,13 @@ function compute<T>(calc: (use: UseV) => T) {
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));
deps.forEach((m) => 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}
/>
</>
);
}
@@ -141,7 +147,9 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
<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 }}
>
@@ -158,6 +166,7 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
/>
<button
type="button"
@@ -174,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;
}
@@ -191,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();
@@ -221,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>
</>
);
@@ -251,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 -19
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,12 +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 (
<div style={{ height: "100%" }} onKeyDown={readonly ? undefined : handleKeyDown}>
<div style={{ height: '100%' }} tabIndex={0} onKeyDown={readonly ? undefined : handleKeyDown}>
<ReactFlowProvider>
<ReactFlow<AnyWorkNode, Edge>
nodes={nodes}
@@ -71,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 />;
}
@@ -84,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,19 +9,19 @@ interface PrivateEvents {
load: WorkFlowSteps;
}
export const InternalField = Symbol("InternalField");
export const InternalField = Symbol('InternalField');
export class Injection extends Eventer<PrivateEvents> {
constructor(
public readonly emitPublic: Eventer<PublicEvents>["emit"],
public readonly emitPublic: Eventer<PublicEvents>['emit'],
private inital_steps?: WorkFlowSteps,
) {
super();
}
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;
}
@@ -37,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,8 +55,8 @@ 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) {
@@ -65,7 +65,7 @@ function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
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() {
@@ -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> = () => {
@@ -31,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;
}
@@ -54,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;
});
@@ -96,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();
@@ -104,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();
}
@@ -132,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,7 +51,7 @@ 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 },
});
@@ -70,16 +60,16 @@ export function transIn(steps: WorkFlowStep[]): Result {
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)!;
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(死端节点)' });
}
}
}
@@ -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,6 +1,4 @@
interface Maper<T> {
[key: string]: T;
}
interface Maper<T> { [key: string]: T }
type Listen<T> = (data: T) => void;
export class Eventer<M extends Maper<any>> {
@@ -8,7 +6,7 @@ export class Eventer<M extends Maper<any>> {
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;
}
@@ -28,6 +26,6 @@ export class Eventer<M extends Maper<any>> {
const set = this.lisenters[key];
if (set === undefined) return;
// Todo: maybe implement stoping bubble
set.forEach((call) => call(data));
set.forEach(call => call(data));
}
}
@@ -1,3 +1,5 @@
export function uuid() {
const now = Date.now();
const randon = 1 + Math.random();
@@ -1,4 +1,5 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef } from 'react';
function judge(container: HTMLElement, target: HTMLElement): boolean {
if (container === target) {
@@ -7,11 +8,14 @@ function judge(container: HTMLElement, target: HTMLElement): boolean {
if (target === document.body) {
return false;
}
const parent = target.parentElement;
let parent = target.parentElement;
return parent ? judge(container, parent) : false;
}
export function useClickOutRef<T extends HTMLElement>(callback: () => void, delay = 0) {
export function useClickOutRef<T extends HTMLElement>(
callback: () => void,
delay = 0,
) {
const ref = useRef<T>(null);
const flag = useRef<boolean>(delay === 0);
@@ -33,8 +37,8 @@ export function useClickOutRef<T extends HTMLElement>(callback: () => void, dela
callback();
}
}
document.addEventListener("click", handle);
return () => document.removeEventListener("click", handle);
document.addEventListener('click', handle);
return () => document.removeEventListener('click', handle);
}, [callback]);
return ref;
+1 -1
View File
@@ -147,4 +147,4 @@ body {
html {
@apply font-sans;
}
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return twMerge(clsx(inputs))
}
@@ -1,8 +1,8 @@
import { ArrowLeft, Eye, Pencil } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router";
import { Button } from "@/components/ui/button";
import { useState, useEffect, useRef, type ReactNode } from "react";
import { useParams, useNavigate, useLocation } from "react-router";
import FlowEditor, { FlowModel, type WorkFlowSteps } from "../editor/flow.tsx";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Pencil, Eye } from "lucide-react";
export function DetailPage(): ReactNode {
const { name } = useParams<{ name: string }>();
@@ -44,14 +44,14 @@ export function DetailPage(): ReactNode {
if (!cancelled) navigate("/");
});
return () => {
cancelled = true;
};
return () => { cancelled = true; };
}, [name, navigate]);
if (loading) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">...</div>
<div className="flex h-full items-center justify-center text-muted-foreground">
...
</div>
);
}
@@ -66,19 +66,29 @@ export function DetailPage(): ReactNode {
<h1 className="text-base font-medium">{name}</h1>
<div className="flex-1" />
{editing ? (
<Button variant="outline" size="sm" onClick={() => navigate(basePath)}>
<Button
variant="outline"
size="sm"
onClick={() => navigate(basePath)}
>
<Eye className="size-3.5" data-icon="inline-start" />
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => navigate(`${basePath}/edit`)}>
<Button
variant="outline"
size="sm"
onClick={() => navigate(`${basePath}/edit`)}
>
<Pencil className="size-3.5" data-icon="inline-start" />
</Button>
)}
{saving && <span className="text-xs text-muted-foreground">...</span>}
</div>
<div className="flex-1">{model && <FlowEditor model={model} readonly={!editing} />}</div>
<div className="flex-1">
{model && <FlowEditor model={model} readonly={!editing} />}
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import { type ReactNode, useState } from "react";
import { useState, useEffect, type ReactNode } from "react";
import FlowEditor, { FlowModel, type WorkFlowSteps } from "../editor/flow.tsx";
const DEFAULT_STEPS: WorkFlowSteps = [
@@ -1,19 +1,19 @@
import { Plus, Trash2, Workflow } from "lucide-react";
import { type FormEvent, type ReactNode, useCallback, useEffect, useState } from "react";
import { useState, useEffect, useCallback, type ReactNode, type FormEvent } from "react";
import { useNavigate } from "react-router";
import { Button } from "@/components/ui/button";
import { Card, CardAction, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardHeader, CardTitle, CardDescription, CardAction } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Plus, Trash2, Workflow } from "lucide-react";
import type { WorkflowSummary } from "../../shared/types.ts";
export function HomePage(): ReactNode {
+1 -1
View File
@@ -1,7 +1,7 @@
import { createBrowserRouter } from "react-router";
import { Layout } from "./app.tsx";
import { DetailPage } from "./pages/detail.tsx";
import { HomePage } from "./pages/home.tsx";
import { DetailPage } from "./pages/detail.tsx";
export const router = createBrowserRouter([
{
+1 -1
View File
@@ -1,5 +1,5 @@
import type { IncomingMessage } from "node:http";
import type { Plugin } from "vite";
import type { IncomingMessage } from "node:http";
import { createApi } from "./server/api.ts";
function buildRequest(req: IncomingMessage, body: string | null): Request {
@@ -85,7 +85,6 @@ export const STEP_NODE_SCHEMA: JSONSchema = {
output: { type: "string", format: "cas_ref" },
detail: { type: "string", format: "cas_ref" },
agent: { type: "string" },
edgePrompt: { type: "string" },
},
additionalProperties: false,
};
-2
View File
@@ -12,8 +12,6 @@ export type StepRecord = {
output: CasRef;
detail: CasRef;
agent: string;
/** Moderator edge prompt that led to this step. Missing in legacy nodes → "". */
edgePrompt: string;
};
// ── 4.2 Workflow 定义 ───────────────────────────────────────────────
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "bun:test";
import { createProcessLogger } from "../src/process-logger/index.js";
+1 -1
View File
@@ -13,13 +13,13 @@ export {
validateFrontmatter,
} from "./frontmatter-markdown/index.js";
export { createLogger } from "./logger.js";
export { createProcessLogger } from "./process-logger/index.js";
export type {
CreateProcessLoggerOptions,
ProcessLogFn,
ProcessLogger,
ProcessLoggerContext,
} from "./process-logger/index.js";
export { createProcessLogger } from "./process-logger/index.js";
export { normalizeRefsField } from "./refs-field.js";
export { err, ok } from "./result.js";
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";