Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a99f84025 | |||
| f61474bec0 | |||
| 9bdb18afd0 | |||
| 2af299f3ce | |||
| d9f79c60a1 | |||
| 485bfcb0b6 | |||
| a47ed06ea5 | |||
| 2ef004eecf | |||
| 2616259a0f | |||
| 23b2c3b47d | |||
| 7d3954097d | |||
| 4a925b98af | |||
| bfea771a52 | |||
| 5e411a1f19 | |||
| 21238f7825 | |||
| 6b3aa4ce35 | |||
| f042c9d640 | |||
| 66bca9ef03 | |||
| 309af39447 | |||
| 86a422f7e2 | |||
| 648f0c6dec | |||
| 8456a8337b | |||
| 9c8b98a551 | |||
| c3272be760 | |||
| c44b773a86 | |||
| 2776f8e419 | |||
| 7b0e256c13 | |||
| c663ba9e9c | |||
| 71b413f20c | |||
| 61be1c662a | |||
| 84e8d70da4 | |||
| 8976f4cf3b | |||
| 07730dd24c | |||
| 4eff4d2370 | |||
| 1d6da18b18 | |||
| c342ff3737 | |||
| 8fe26417cf | |||
| 990200230b | |||
| 4eaefd9974 | |||
| 1a685583bd | |||
| 19769efea6 | |||
| 74e3f5434c | |||
| 703ac9dfcc | |||
| 2df8accf2f |
@@ -0,0 +1,71 @@
|
|||||||
|
# @uncaged/workflow
|
||||||
|
|
||||||
|
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32).
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
| Concept | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Workflow** | A single-file ESM module exporting `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash. |
|
||||||
|
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
|
||||||
|
| **Thread** | A single execution of a workflow, identified by a ULID. Persisted as `.data.jsonl` + `.info.jsonl`. |
|
||||||
|
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. Roles live inside template packages (`src/roles/`). |
|
||||||
|
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
|
||||||
|
| **CAS** | Content-Addressed Storage — bundles are immutable and addressed by hash. |
|
||||||
|
|
||||||
|
## Monorepo Packages
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/
|
||||||
|
workflow/ # @uncaged/workflow — core lib (types, engine, hash, ULID, registry)
|
||||||
|
cli-workflow/ # @uncaged/cli-workflow — CLI (`uncaged-workflow` command)
|
||||||
|
workflow-template-develop/ # @uncaged/workflow-template-develop — develop workflow template (includes roles)
|
||||||
|
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue — solve-issue workflow template (includes roles)
|
||||||
|
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — Hermes agent adapter
|
||||||
|
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — Cursor agent adapter
|
||||||
|
workflow-agent-llm/ # @uncaged/workflow-agent-llm — LLM agent adapter
|
||||||
|
workflow-util-agent/ # @uncaged/workflow-util-agent — agent utilities (buildAgentPrompt, spawnCli)
|
||||||
|
```
|
||||||
|
|
||||||
|
Managed with **bun workspace** using the `workspace:*` protocol.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# Build all packages
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# Register a workflow bundle
|
||||||
|
uncaged-workflow workflow add solve-issue dist/packages/workflow-template-solve-issue/solve-issue.esm.js
|
||||||
|
|
||||||
|
# Run a workflow
|
||||||
|
uncaged-workflow run solve-issue --prompt "Fix bug #42"
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uncaged-workflow help # Show all commands
|
||||||
|
uncaged-workflow workflow list # List registered workflows
|
||||||
|
uncaged-workflow run <name> # Start a workflow thread
|
||||||
|
uncaged-workflow thread list # List all threads
|
||||||
|
uncaged-workflow thread show <id> # Inspect a thread
|
||||||
|
uncaged-workflow skill # Agent-consumable reference docs
|
||||||
|
```
|
||||||
|
|
||||||
|
See `uncaged-workflow help` for the full command reference.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run check # Biome lint + format check
|
||||||
|
bun run format # Auto-format with Biome
|
||||||
|
bun test # Run tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, bundle contract, storage layout, and design decisions.
|
||||||
@@ -17,11 +17,8 @@ A workflow engine that executes single-file ESM bundles. Each workflow is a self
|
|||||||
| `workflow-agent-cursor` | `@uncaged/workflow-agent-cursor` | Cursor CLI agent (extracts workspace from ctx) |
|
| `workflow-agent-cursor` | `@uncaged/workflow-agent-cursor` | Cursor CLI agent (extracts workspace from ctx) |
|
||||||
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | Hermes CLI agent |
|
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | Hermes CLI agent |
|
||||||
| `workflow-agent-llm` | `@uncaged/workflow-agent-llm` | OpenAI-compatible LLM agent |
|
| `workflow-agent-llm` | `@uncaged/workflow-agent-llm` | OpenAI-compatible LLM agent |
|
||||||
| `workflow-role-planner` | `@uncaged/workflow-role-planner` | Pure data: phased planning prompt + schema |
|
| `workflow-template-develop` | `@uncaged/workflow-template-develop` | Develop workflow template (roles in `src/roles/`) |
|
||||||
| `workflow-role-coder` | `@uncaged/workflow-role-coder` | Pure data: coding prompt + schema |
|
| `workflow-template-solve-issue` | `@uncaged/workflow-template-solve-issue` | Solve-issue workflow template (roles in `src/roles/`) |
|
||||||
| `workflow-role-reviewer` | `@uncaged/workflow-role-reviewer` | Pure data: code review prompt + schema |
|
|
||||||
| `workflow-role-committer` | `@uncaged/workflow-role-committer` | Pure data: git commit prompt + schema |
|
|
||||||
| `workflow-template-solve-issue` | `@uncaged/workflow-template-solve-issue` | Composes roles + moderator into a complete workflow |
|
|
||||||
| `workflow-util-agent` | `@uncaged/workflow-util-agent` | `buildAgentPrompt` + `spawnCli` utilities |
|
| `workflow-util-agent` | `@uncaged/workflow-util-agent` | `buildAgentPrompt` + `spawnCli` utilities |
|
||||||
|
|
||||||
Monorepo with **bun workspace**, `workspace:*` protocol.
|
Monorepo with **bun workspace**, `workspace:*` protocol.
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
"examples"
|
"examples"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun run --filter '*' build",
|
|
||||||
"check": "bunx tsc --build && biome check .",
|
"check": "bunx tsc --build && biome check .",
|
||||||
"typecheck": "bunx tsc --build",
|
"typecheck": "bunx tsc --build",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ParsedAddArgv } from "../src/cmd-add.js";
|
import type { ParsedAddArgv } from "../src/commands/workflow/add.js";
|
||||||
|
|
||||||
export function addCliArgs(name: string, filePath: string): ParsedAddArgv {
|
export function addCliArgs(name: string, filePath: string): ParsedAddArgv {
|
||||||
return { name, filePath, typesPath: null };
|
return { name, filePath, typesPath: null };
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import { tmpdir } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { getGlobalCasDir, getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
|
import { getGlobalCasDir, getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
|
||||||
import { cmdAdd } from "../src/cmd-add.js";
|
import { cmdCasGet } from "../src/commands/cas/get.js";
|
||||||
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/cmd-cas.js";
|
import { cmdCasList } from "../src/commands/cas/list.js";
|
||||||
import { cmdHistory } from "../src/cmd-history.js";
|
import { cmdCasPut } from "../src/commands/cas/put.js";
|
||||||
import { cmdList, formatListLines } from "../src/cmd-list.js";
|
import { cmdCasRm } from "../src/commands/cas/rm.js";
|
||||||
import { cmdRemove } from "../src/cmd-remove.js";
|
import { cmdAdd } from "../src/commands/workflow/add.js";
|
||||||
import { cmdRollback } from "../src/cmd-rollback.js";
|
import { cmdHistory } from "../src/commands/workflow/history.js";
|
||||||
import { cmdShow } from "../src/cmd-show.js";
|
import { cmdList, formatListLines } from "../src/commands/workflow/list.js";
|
||||||
|
import { cmdRemove } from "../src/commands/workflow/rm.js";
|
||||||
|
import { cmdRollback } from "../src/commands/workflow/rollback.js";
|
||||||
|
import { cmdShow } from "../src/commands/workflow/show.js";
|
||||||
import { addCliArgs } from "./bundle-fixture.js";
|
import { addCliArgs } from "./bundle-fixture.js";
|
||||||
|
|
||||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} };
|
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} };
|
||||||
|
|||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{"name":"demo-live","hash":"C9NMV6V2TQT81","threadId":"01LIVECMPLT01DDDDDDDDDDDDG","parameters":{"prompt":"hello","options":{"maxRounds":5,"depth":0}},"timestamp":1714963400000}
|
||||||
|
{"role":"planner","contentHash":"FF7YQ5W3S2EV6","meta":{"phase":"plan","flags":[1,2]},"refs":[],"timestamp":1714963201000}
|
||||||
|
{"role":"coder","contentHash":"EN34XX1W4WAFJ","meta":{},"refs":[],"timestamp":1714963202000}
|
||||||
|
{"returnCode":0,"summary":"fixture completed"}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
{"tag":"DEBUGTAG1","content":"bundle loaded","timestamp":1714963400050}
|
||||||
|
{"tag":"DEBUGTAG2","content":"multi\nline","timestamp":1714963400500}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
{"name":"demo-live","hash":"C9NMV6V2TQT81","threadId":"01LIVEINFLY01DDDDDDDDDDDDG","parameters":{"prompt":"hello","options":{"maxRounds":5,"depth":0}},"timestamp":1714963200000}
|
||||||
|
{"role":"planner","contentHash":"P6M9FHE1GSBN0","meta":{"x":1},"refs":[],"timestamp":1714963201000}
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
{"name":"demo-live-old","hash":"C9NMV6V2TQT81","threadId":"01LIVEOLDER01DDDDDDDDDDDDG","parameters":{"prompt":"old","options":{"maxRounds":5,"depth":0}},"timestamp":1714963000000}
|
||||||
|
{"returnCode":0,"summary":"older thread"}
|
||||||
@@ -3,9 +3,9 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createCasStore, getContentMerklePayload, getGlobalCasDir } from "@uncaged/workflow";
|
import { createCasStore, getContentMerklePayload, getGlobalCasDir } from "@uncaged/workflow";
|
||||||
import { cmdAdd } from "../src/cmd-add.js";
|
import { cmdFork } from "../src/commands/thread/fork.js";
|
||||||
import { cmdFork } from "../src/cmd-fork.js";
|
import { cmdRun } from "../src/commands/thread/run.js";
|
||||||
import { cmdRun } from "../src/cmd-run.js";
|
import { cmdAdd } from "../src/commands/workflow/add.js";
|
||||||
import { pathExists } from "../src/fs-utils.js";
|
import { pathExists } from "../src/fs-utils.js";
|
||||||
import { addCliArgs } from "./bundle-fixture.js";
|
import { addCliArgs } from "./bundle-fixture.js";
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ describe("cli fork", () => {
|
|||||||
const sourceData = join(storageRoot, "logs", hash, `${sourceId}.data.jsonl`);
|
const sourceData = join(storageRoot, "logs", hash, `${sourceId}.data.jsonl`);
|
||||||
const sourceRunning = join(storageRoot, "logs", hash, `${sourceId}.running`);
|
const sourceRunning = join(storageRoot, "logs", hash, `${sourceId}.running`);
|
||||||
await waitUntilRunningAbsent(sourceRunning);
|
await waitUntilRunningAbsent(sourceRunning);
|
||||||
await waitUntilMinDataLines(sourceData, 4);
|
await waitUntilMinDataLines(sourceData, 5);
|
||||||
|
|
||||||
const forked = await cmdFork(storageRoot, sourceId, "planner");
|
const forked = await cmdFork(storageRoot, sourceId, "planner");
|
||||||
expect(forked.ok).toBe(true);
|
expect(forked.ok).toBe(true);
|
||||||
@@ -122,22 +122,22 @@ describe("cli fork", () => {
|
|||||||
const newData = join(storageRoot, "logs", hash, `${newId}.data.jsonl`);
|
const newData = join(storageRoot, "logs", hash, `${newId}.data.jsonl`);
|
||||||
const newRunning = join(storageRoot, "logs", hash, `${newId}.running`);
|
const newRunning = join(storageRoot, "logs", hash, `${newId}.running`);
|
||||||
await waitUntilRunningAbsent(newRunning);
|
await waitUntilRunningAbsent(newRunning);
|
||||||
await waitUntilMinDataLines(newData, 4);
|
await waitUntilMinDataLines(newData, 5);
|
||||||
|
|
||||||
const text = await readFile(newData, "utf8");
|
const text = await readFile(newData, "utf8");
|
||||||
const lines = text
|
const lines = text
|
||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter((l) => l !== "");
|
.filter((l) => l !== "");
|
||||||
expect(lines.length).toBe(4);
|
expect(lines.length).toBe(5);
|
||||||
const start = JSON.parse(lines[0] ?? "{}") as Record<string, unknown>;
|
const start = JSON.parse(lines[0] ?? "{}") as Record<string, unknown>;
|
||||||
expect(start.threadId).toBe(newId);
|
expect(start.threadId).toBe(newId);
|
||||||
expect(start.forkFrom).toEqual({ threadId: sourceId });
|
expect(start.forkFrom).toEqual({ threadId: sourceId });
|
||||||
|
|
||||||
const last = JSON.parse(lines[lines.length - 1] ?? "{}") as Record<string, unknown>;
|
const lastRoleLine = JSON.parse(lines[lines.length - 2] ?? "{}") as Record<string, unknown>;
|
||||||
expect(last.role).toBe("reviewer");
|
expect(lastRoleLine.role).toBe("reviewer");
|
||||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||||
expect(await getContentMerklePayload(cas, String(last.contentHash))).toBe("rev-1");
|
expect(await getContentMerklePayload(cas, String(lastRoleLine.contentHash))).toBe("rev-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("fork without --from-role retries last role", async () => {
|
test("fork without --from-role retries last role", async () => {
|
||||||
@@ -162,7 +162,7 @@ describe("cli fork", () => {
|
|||||||
const sourceData = join(storageRoot, "logs", hash, `${sourceId}.data.jsonl`);
|
const sourceData = join(storageRoot, "logs", hash, `${sourceId}.data.jsonl`);
|
||||||
const sourceRunning = join(storageRoot, "logs", hash, `${sourceId}.running`);
|
const sourceRunning = join(storageRoot, "logs", hash, `${sourceId}.running`);
|
||||||
await waitUntilRunningAbsent(sourceRunning);
|
await waitUntilRunningAbsent(sourceRunning);
|
||||||
await waitUntilMinDataLines(sourceData, 4);
|
await waitUntilMinDataLines(sourceData, 5);
|
||||||
|
|
||||||
const forked = await cmdFork(storageRoot, sourceId, null);
|
const forked = await cmdFork(storageRoot, sourceId, null);
|
||||||
expect(forked.ok).toBe(true);
|
expect(forked.ok).toBe(true);
|
||||||
@@ -173,23 +173,23 @@ describe("cli fork", () => {
|
|||||||
const newData = join(storageRoot, "logs", hash, `${newId}.data.jsonl`);
|
const newData = join(storageRoot, "logs", hash, `${newId}.data.jsonl`);
|
||||||
const newRunning = join(storageRoot, "logs", hash, `${newId}.running`);
|
const newRunning = join(storageRoot, "logs", hash, `${newId}.running`);
|
||||||
await waitUntilRunningAbsent(newRunning);
|
await waitUntilRunningAbsent(newRunning);
|
||||||
await waitUntilMinDataLines(newData, 4);
|
await waitUntilMinDataLines(newData, 5);
|
||||||
|
|
||||||
const text = await readFile(newData, "utf8");
|
const text = await readFile(newData, "utf8");
|
||||||
const lines = text
|
const lines = text
|
||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter((l) => l !== "");
|
.filter((l) => l !== "");
|
||||||
expect(lines.length).toBe(4);
|
expect(lines.length).toBe(5);
|
||||||
|
|
||||||
const replayCoder = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
const replayCoder = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||||
expect(replayCoder.role).toBe("coder");
|
expect(replayCoder.role).toBe("coder");
|
||||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||||
expect(await getContentMerklePayload(cas, String(replayCoder.contentHash))).toBe("c1");
|
expect(await getContentMerklePayload(cas, String(replayCoder.contentHash))).toBe("c1");
|
||||||
|
|
||||||
const last = JSON.parse(lines[lines.length - 1] ?? "{}") as Record<string, unknown>;
|
const lastRoleLine = JSON.parse(lines[lines.length - 2] ?? "{}") as Record<string, unknown>;
|
||||||
expect(last.role).toBe("reviewer");
|
expect(lastRoleLine.role).toBe("reviewer");
|
||||||
expect(await getContentMerklePayload(cas, String(last.contentHash))).toBe("rev-2");
|
expect(await getContentMerklePayload(cas, String(lastRoleLine.contentHash))).toBe("rev-2");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("fork rejects unknown role with available names", async () => {
|
test("fork rejects unknown role with available names", async () => {
|
||||||
@@ -213,7 +213,7 @@ describe("cli fork", () => {
|
|||||||
const sourceData = join(storageRoot, "logs", added.value.hash, `${sourceId}.data.jsonl`);
|
const sourceData = join(storageRoot, "logs", added.value.hash, `${sourceId}.data.jsonl`);
|
||||||
const sourceRunning = join(storageRoot, "logs", added.value.hash, `${sourceId}.running`);
|
const sourceRunning = join(storageRoot, "logs", added.value.hash, `${sourceId}.running`);
|
||||||
await waitUntilRunningAbsent(sourceRunning);
|
await waitUntilRunningAbsent(sourceRunning);
|
||||||
await waitUntilMinDataLines(sourceData, 4);
|
await waitUntilMinDataLines(sourceData, 5);
|
||||||
|
|
||||||
const bad = await cmdFork(storageRoot, sourceId, "ghost-role");
|
const bad = await cmdFork(storageRoot, sourceId, "ghost-role");
|
||||||
expect(bad.ok).toBe(false);
|
expect(bad.ok).toBe(false);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
getGlobalCasDir,
|
getGlobalCasDir,
|
||||||
putContentMerkleNode,
|
putContentMerkleNode,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow";
|
||||||
import { cmdThreadRemove } from "../src/cmd-thread.js";
|
import { cmdThreadRemove } from "../src/commands/thread/rm.js";
|
||||||
import { pathExists } from "../src/fs-utils.js";
|
import { pathExists } from "../src/fs-utils.js";
|
||||||
|
|
||||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||||
@@ -129,7 +129,10 @@ describe("gc cli and garbageCollectCas", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||||
const proc = spawnSync(process.execPath, [cliEntryPath, "gc"], { env, encoding: "utf8" });
|
const proc = spawnSync(process.execPath, [cliEntryPath, "cas", "gc"], {
|
||||||
|
env,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
expect(proc.status).toBe(0);
|
expect(proc.status).toBe(0);
|
||||||
expect(String(proc.stdout).trim()).toBe("scanned 1 threads, 2 active refs, deleted 1 entries");
|
expect(String(proc.stdout).trim()).toBe("scanned 1 threads, 2 active refs, deleted 1 entries");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { formatCliUsage, runCli } from "../src/cli-dispatch.js";
|
||||||
|
import {
|
||||||
|
formatSkillDoc,
|
||||||
|
formatSkillIndex,
|
||||||
|
formatSkillTopic,
|
||||||
|
getSkillTopics,
|
||||||
|
} from "../src/skill.js";
|
||||||
|
|
||||||
|
const STORAGE_ROOT = "/tmp/help-test-storage";
|
||||||
|
|
||||||
|
describe("help command", () => {
|
||||||
|
test("help returns 0", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, ["help"]);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no args prints usage (not red) and returns 1", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, []);
|
||||||
|
expect(code).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("skill command", () => {
|
||||||
|
test("skill (no topic) lists topics and returns 0", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, ["skill"]);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skill cli returns 0", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, ["skill", "cli"]);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skill develop returns 0", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, ["skill", "develop"]);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skill author returns 0", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, ["skill", "author"]);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skill unknown returns 1", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, ["skill", "unknown"]);
|
||||||
|
expect(code).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("--help flag on groups", () => {
|
||||||
|
test("workflow --help returns 0", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, ["workflow", "--help"]);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("thread --help returns 0", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, ["thread", "--help"]);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cas --help returns 0", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, ["cas", "--help"]);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("init --help returns 0", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, ["init", "--help"]);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("legacy help --skill compat", () => {
|
||||||
|
test("help --skill still works (lists topics)", async () => {
|
||||||
|
const code = await runCli(STORAGE_ROOT, ["help", "--skill"]);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSkillTopics", () => {
|
||||||
|
test("returns all topics", () => {
|
||||||
|
const topics = getSkillTopics();
|
||||||
|
const names = topics.map((t) => t.name);
|
||||||
|
expect(names).toContain("cli");
|
||||||
|
expect(names).toContain("develop");
|
||||||
|
expect(names).toContain("author");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatSkillIndex", () => {
|
||||||
|
test("lists all topics", () => {
|
||||||
|
const idx = formatSkillIndex();
|
||||||
|
expect(idx).toContain("# uncaged-workflow skill");
|
||||||
|
expect(idx).not.toContain("# uncaged-workflow help --skill");
|
||||||
|
expect(idx).toContain("cli");
|
||||||
|
expect(idx).toContain("develop");
|
||||||
|
expect(idx).toContain("author");
|
||||||
|
expect(idx).toContain("skill <topic>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatCliUsage", () => {
|
||||||
|
test("has tagline, grouped sections, help hint, and env vars", () => {
|
||||||
|
const u = formatCliUsage();
|
||||||
|
expect(u.startsWith("uncaged-workflow — workflow engine CLI")).toBe(true);
|
||||||
|
expect(u).toContain("Workflow registry:");
|
||||||
|
expect(u).toContain("Thread execution:");
|
||||||
|
expect(u).toContain("Content-addressable storage:");
|
||||||
|
expect(u).toContain("Development:");
|
||||||
|
expect(u).toContain("Shortcuts:");
|
||||||
|
expect(u).toContain("Reference:");
|
||||||
|
expect(u).toContain("skill [topic]");
|
||||||
|
expect(u).toContain("Agent-consumable docs");
|
||||||
|
expect(u).toContain("Use <command> --help for subcommand details.");
|
||||||
|
expect(u).toContain("Environment variables:");
|
||||||
|
expect(u).toContain("WORKFLOW_STORAGE_ROOT");
|
||||||
|
expect(u).toContain("UNCAGED_WORKFLOW_STORAGE_ROOT");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lists commands from registry with descriptions", () => {
|
||||||
|
const u = formatCliUsage();
|
||||||
|
expect(u).toContain("workflow add");
|
||||||
|
expect(u).toContain("Register a workflow bundle in the registry");
|
||||||
|
expect(u).toContain("thread run");
|
||||||
|
expect(u).toContain("Start a new thread executing a workflow");
|
||||||
|
expect(u).toContain("cas gc");
|
||||||
|
expect(u).toContain("Garbage-collect unreferenced CAS entries");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatSkillTopic('cli') — legacy formatSkillDoc", () => {
|
||||||
|
const doc = formatSkillDoc();
|
||||||
|
|
||||||
|
test("contains title", () => {
|
||||||
|
expect(doc).toContain("# uncaged-workflow CLI Reference");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains all command group headers", () => {
|
||||||
|
expect(doc).toContain("### workflow");
|
||||||
|
expect(doc).toContain("### thread");
|
||||||
|
expect(doc).toContain("### cas");
|
||||||
|
expect(doc).toContain("### init");
|
||||||
|
expect(doc).toContain("### Top-level shortcuts");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains core concepts", () => {
|
||||||
|
expect(doc).toContain("## Core Concepts");
|
||||||
|
expect(doc).toContain("Workflow");
|
||||||
|
expect(doc).toContain("Bundle");
|
||||||
|
expect(doc).toContain("Thread");
|
||||||
|
expect(doc).toContain("CAS");
|
||||||
|
expect(doc).toContain("Registry");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mentions all workflow subcommands", () => {
|
||||||
|
for (const sub of ["add", "list", "show", "rm", "history", "rollback"]) {
|
||||||
|
expect(doc).toContain(`workflow ${sub}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mentions all thread subcommands", () => {
|
||||||
|
for (const sub of [
|
||||||
|
"run",
|
||||||
|
"list",
|
||||||
|
"show",
|
||||||
|
"rm",
|
||||||
|
"fork",
|
||||||
|
"ps",
|
||||||
|
"kill",
|
||||||
|
"live",
|
||||||
|
"pause",
|
||||||
|
"resume",
|
||||||
|
]) {
|
||||||
|
expect(doc).toContain(`thread ${sub}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mentions all cas subcommands", () => {
|
||||||
|
for (const sub of ["get", "put", "list", "rm", "gc"]) {
|
||||||
|
expect(doc).toContain(`cas ${sub}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains exit codes section", () => {
|
||||||
|
expect(doc).toContain("## Exit Codes");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains environment variables section", () => {
|
||||||
|
expect(doc).toContain("## Environment Variables");
|
||||||
|
expect(doc).toContain("UNCAGED_WORKFLOW_STORAGE_ROOT");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains typical workflow section", () => {
|
||||||
|
expect(doc).toContain("## Typical Workflow");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatSkillTopic('develop')", () => {
|
||||||
|
const doc = formatSkillTopic("develop");
|
||||||
|
|
||||||
|
test("returns non-null", () => {
|
||||||
|
expect(doc).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains thread ID info", () => {
|
||||||
|
expect(doc).toContain("Thread ID");
|
||||||
|
expect(doc).toContain("Crockford Base32");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains CAS commands", () => {
|
||||||
|
expect(doc).toContain("cas put");
|
||||||
|
expect(doc).toContain("cas get");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains meta output section", () => {
|
||||||
|
expect(doc).toContain("Meta Output");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatSkillTopic('author')", () => {
|
||||||
|
const doc = formatSkillTopic("author");
|
||||||
|
|
||||||
|
test("returns non-null", () => {
|
||||||
|
expect(doc).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains bundle structure", () => {
|
||||||
|
expect(doc).toContain("Bundle Structure");
|
||||||
|
expect(doc).toContain(".esm.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains descriptor info", () => {
|
||||||
|
expect(doc).toContain("WorkflowDescriptor");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contains role definition", () => {
|
||||||
|
expect(doc).toContain("Role Definition");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatSkillTopic unknown", () => {
|
||||||
|
test("returns null for unknown topic", () => {
|
||||||
|
expect(formatSkillTopic("nonexistent")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,8 @@ import { tmpdir } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { runCli } from "../src/cli-dispatch.js";
|
import { runCli } from "../src/cli-dispatch.js";
|
||||||
import { cmdInitTemplate, cmdInitWorkspace } from "../src/cmd-init.js";
|
import { cmdInitTemplate } from "../src/commands/init/template.js";
|
||||||
|
import { cmdInitWorkspace } from "../src/commands/init/workspace.js";
|
||||||
import { pathExists } from "../src/fs-utils.js";
|
import { pathExists } from "../src/fs-utils.js";
|
||||||
|
|
||||||
describe("init template", () => {
|
describe("init template", () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { formatCliUsage, runCli } from "../src/cli-dispatch.js";
|
import { formatCliUsage, runCli } from "../src/cli-dispatch.js";
|
||||||
import { cmdInitWorkspace } from "../src/cmd-init.js";
|
import { cmdInitWorkspace } from "../src/commands/init/workspace.js";
|
||||||
import { pathExists } from "../src/fs-utils.js";
|
import { pathExists } from "../src/fs-utils.js";
|
||||||
|
|
||||||
describe("init workspace", () => {
|
describe("init workspace", () => {
|
||||||
@@ -129,8 +129,9 @@ describe("init workspace", () => {
|
|||||||
|
|
||||||
test("usage lists init subcommands", () => {
|
test("usage lists init subcommands", () => {
|
||||||
const u = formatCliUsage();
|
const u = formatCliUsage();
|
||||||
expect(u).toContain("uncaged-workflow init workspace <name>");
|
expect(u).toContain("init workspace <name>");
|
||||||
expect(u).toContain("uncaged-workflow init template <name>");
|
expect(u).toContain("init template <name>");
|
||||||
|
expect(u).toContain("Development:");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("runCli rejects unknown init subcommand", async () => {
|
test("runCli rejects unknown init subcommand", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||||
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
|
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { createCasStore, getGlobalCasDir, putContentMerkleNode } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatLiveDebugLine,
|
||||||
|
formatLiveTimeLabel,
|
||||||
|
LIVE_CONTENT_MAX_LINES,
|
||||||
|
type LiveRoleRow,
|
||||||
|
renderLiveRoleStepLines,
|
||||||
|
} from "../src/commands/thread/live.js";
|
||||||
|
import { parseLiveArgv } from "../src/live-argv.js";
|
||||||
|
|
||||||
|
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||||
|
const fixtureRoot = fileURLToPath(new URL("./fixtures/live", import.meta.url));
|
||||||
|
|
||||||
|
/** Bodies for Merkle content nodes; hashes must match `.data.jsonl` fixtures. */
|
||||||
|
const LIVE_FIXTURE_PLANNER_BODY =
|
||||||
|
"alpha\nbeta\ngamma\nLINE4\nLINE5\nLINE6\nLINE7\nLINE8\nLINE9\nLINE10\nLINE11";
|
||||||
|
|
||||||
|
describe("live helpers", () => {
|
||||||
|
test("formatLiveTimeLabel pads HH:MM:SS", () => {
|
||||||
|
const label = formatLiveTimeLabel(new Date("2024-06-01T09:08:07.000Z").getTime());
|
||||||
|
expect(label).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatLiveDebugLine flattens newlines in message", () => {
|
||||||
|
const line = formatLiveDebugLine(0, "TAG1", "a\nb");
|
||||||
|
expect(line).toContain("[TAG1]");
|
||||||
|
expect(line).toContain("a b");
|
||||||
|
expect(line).not.toContain("\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renderLiveRoleStepLines truncates content to LIVE_CONTENT_MAX_LINES", () => {
|
||||||
|
const lines = Array.from({ length: LIVE_CONTENT_MAX_LINES + 3 }, (_, i) => `L${i + 1}`);
|
||||||
|
const row: LiveRoleRow = {
|
||||||
|
role: "r",
|
||||||
|
content: lines.join("\n"),
|
||||||
|
meta: { k: "v" },
|
||||||
|
timestamp: 0,
|
||||||
|
};
|
||||||
|
const out = renderLiveRoleStepLines(row, "r");
|
||||||
|
const body = out.filter((l) => l.startsWith(" L"));
|
||||||
|
expect(body.length).toBe(LIVE_CONTENT_MAX_LINES);
|
||||||
|
expect(out.some((l) => l.includes("more line"))).toBe(true);
|
||||||
|
expect(out.some((l) => l.startsWith(" meta: "))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseLiveArgv", () => {
|
||||||
|
test("parses thread id and flags in any order", () => {
|
||||||
|
const a = parseLiveArgv(["01ABC", "--debug", "--role", "planner"]);
|
||||||
|
expect(a.ok).toBe(true);
|
||||||
|
if (a.ok) {
|
||||||
|
expect(a.value.threadId).toBe("01ABC");
|
||||||
|
expect(a.value.latest).toBe(false);
|
||||||
|
expect(a.value.debug).toBe(true);
|
||||||
|
expect(a.value.role).toBe("planner");
|
||||||
|
}
|
||||||
|
const b = parseLiveArgv(["--latest", "--role", "x"]);
|
||||||
|
expect(b.ok).toBe(true);
|
||||||
|
if (b.ok) {
|
||||||
|
expect(b.value.latest).toBe(true);
|
||||||
|
expect(b.value.threadId).toBe(null);
|
||||||
|
expect(b.value.role).toBe("x");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects --latest with thread id", () => {
|
||||||
|
const r = parseLiveArgv(["--latest", "01ABC"]);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("live CLI", () => {
|
||||||
|
let prevEnv: string | undefined;
|
||||||
|
let storageRoot: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-live-"));
|
||||||
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||||
|
await mkdir(join(storageRoot, "logs", "C9NMV6V2TQT81"), { recursive: true });
|
||||||
|
await cp(
|
||||||
|
join(fixtureRoot, "logs", "C9NMV6V2TQT81", "01LIVECMPLT01DDDDDDDDDDDDG.data.jsonl"),
|
||||||
|
join(storageRoot, "logs", "C9NMV6V2TQT81", "01LIVECMPLT01DDDDDDDDDDDDG.data.jsonl"),
|
||||||
|
);
|
||||||
|
await cp(
|
||||||
|
join(fixtureRoot, "logs", "C9NMV6V2TQT81", "01LIVECMPLT01DDDDDDDDDDDDG.info.jsonl"),
|
||||||
|
join(storageRoot, "logs", "C9NMV6V2TQT81", "01LIVECMPLT01DDDDDDDDDDDDG.info.jsonl"),
|
||||||
|
);
|
||||||
|
await cp(
|
||||||
|
join(fixtureRoot, "logs", "C9NMV6V2TQT81", "01LIVEINFLY01DDDDDDDDDDDDG.data.jsonl"),
|
||||||
|
join(storageRoot, "logs", "C9NMV6V2TQT81", "01LIVEINFLY01DDDDDDDDDDDDG.data.jsonl"),
|
||||||
|
);
|
||||||
|
await cp(
|
||||||
|
join(fixtureRoot, "logs", "C9NMV6V2TQT81", "01LIVEOLDER01DDDDDDDDDDDDG.data.jsonl"),
|
||||||
|
join(storageRoot, "logs", "C9NMV6V2TQT81", "01LIVEOLDER01DDDDDDDDDDDDG.data.jsonl"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||||
|
await putContentMerkleNode(cas, LIVE_FIXTURE_PLANNER_BODY);
|
||||||
|
await putContentMerkleNode(cas, "patch");
|
||||||
|
await putContentMerkleNode(cas, "still running");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (prevEnv === undefined) {
|
||||||
|
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
} else {
|
||||||
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
|
||||||
|
}
|
||||||
|
await rm(storageRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prints role steps and summary for a completed thread", async () => {
|
||||||
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||||
|
const proc = spawn(process.execPath, [cliEntryPath, "live", "01LIVECMPLT01DDDDDDDDDDDDG"], {
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
const stdout = await new Promise<string>((resolve, reject) => {
|
||||||
|
let buf = "";
|
||||||
|
proc.stdout?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.stderr?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.on("error", reject);
|
||||||
|
proc.on("exit", (code: number | null) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve(buf);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`exit ${code}: ${buf}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdout).toContain("planner");
|
||||||
|
expect(stdout).toContain("coder");
|
||||||
|
expect(stdout).toContain("meta:");
|
||||||
|
expect(stdout).toContain('"phase":"plan"');
|
||||||
|
expect(stdout).toContain("LINE10");
|
||||||
|
expect(stdout).not.toContain("LINE11");
|
||||||
|
expect(stdout).toContain("more line");
|
||||||
|
expect(stdout).toContain("completed: returnCode=0");
|
||||||
|
expect(stdout).toContain("fixture completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("--latest tails the newest thread by start timestamp", async () => {
|
||||||
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||||
|
const proc = spawn(process.execPath, [cliEntryPath, "live", "--latest"], {
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
const stdout = await new Promise<string>((resolve, reject) => {
|
||||||
|
let buf = "";
|
||||||
|
proc.stdout?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.stderr?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.on("error", reject);
|
||||||
|
proc.on("exit", (code: number | null) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve(buf);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`exit ${code}: ${buf}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdout).toContain("fixture completed");
|
||||||
|
expect(stdout).not.toContain("older thread");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("--debug prints .info.jsonl records after data output", async () => {
|
||||||
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||||
|
const proc = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[cliEntryPath, "live", "01LIVECMPLT01DDDDDDDDDDDDG", "--debug"],
|
||||||
|
{
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const stdout = await new Promise<string>((resolve, reject) => {
|
||||||
|
let buf = "";
|
||||||
|
proc.stdout?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.stderr?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.on("error", reject);
|
||||||
|
proc.on("exit", (code: number | null) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve(buf);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`exit ${code}: ${buf}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdout).toContain("[DEBUGTAG1]");
|
||||||
|
expect(stdout).toContain("bundle loaded");
|
||||||
|
expect(stdout).toContain("[DEBUGTAG2]");
|
||||||
|
expect(stdout).toContain("multi line");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("--role filters out non-matching roles", async () => {
|
||||||
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||||
|
const proc = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[cliEntryPath, "live", "01LIVECMPLT01DDDDDDDDDDDDG", "--role", "planner"],
|
||||||
|
{
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const stdout = await new Promise<string>((resolve, reject) => {
|
||||||
|
let buf = "";
|
||||||
|
proc.stdout?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.stderr?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.on("error", reject);
|
||||||
|
proc.on("exit", (code: number | null) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve(buf);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`exit ${code}: ${buf}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdout).toContain("planner");
|
||||||
|
expect(stdout).not.toContain("patch");
|
||||||
|
expect(stdout).toContain("completed: returnCode=0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("--latest --debug --role combine", async () => {
|
||||||
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||||
|
const proc = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[cliEntryPath, "live", "--latest", "--debug", "--role", "planner"],
|
||||||
|
{
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const stdout = await new Promise<string>((resolve, reject) => {
|
||||||
|
let buf = "";
|
||||||
|
proc.stdout?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.stderr?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.on("error", reject);
|
||||||
|
proc.on("exit", (code: number | null) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve(buf);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`exit ${code}: ${buf}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdout).toContain("[DEBUGTAG1]");
|
||||||
|
expect(stdout).toContain("planner");
|
||||||
|
expect(stdout).not.toContain("patch");
|
||||||
|
expect(stdout).toContain("fixture completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown thread id exits 1", () => {
|
||||||
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||||
|
const r = spawnSync(process.execPath, [cliEntryPath, "live", "01UNKNOWNXXXXXXXXXXXXXXXXX"], {
|
||||||
|
env,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(1);
|
||||||
|
expect(String(r.stderr ?? "")).toContain("thread not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("follows file until WorkflowResult is appended", async () => {
|
||||||
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||||
|
const dataPath = join(
|
||||||
|
storageRoot,
|
||||||
|
"logs",
|
||||||
|
"C9NMV6V2TQT81",
|
||||||
|
"01LIVEINFLY01DDDDDDDDDDDDG.data.jsonl",
|
||||||
|
);
|
||||||
|
|
||||||
|
const proc = spawn(process.execPath, [cliEntryPath, "live", "01LIVEINFLY01DDDDDDDDDDDDG"], {
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
const prior = await readFile(dataPath, "utf8");
|
||||||
|
await writeFile(
|
||||||
|
dataPath,
|
||||||
|
`${prior.replace(/\s*$/, "")}\n${JSON.stringify({ returnCode: 0, summary: "caught up" })}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const stdout = await new Promise<string>((resolve, reject) => {
|
||||||
|
let buf = "";
|
||||||
|
proc.stdout?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.stderr?.on("data", (c: Buffer) => {
|
||||||
|
buf += c.toString("utf8");
|
||||||
|
});
|
||||||
|
proc.on("error", reject);
|
||||||
|
proc.on("exit", (code: number | null) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve(buf);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`exit ${code}: ${buf}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdout).toContain("planner");
|
||||||
|
expect(stdout).toContain("completed: returnCode=0");
|
||||||
|
expect(stdout).toContain("caught up");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("live --latest with empty storage", () => {
|
||||||
|
let prevEnv: string | undefined;
|
||||||
|
let emptyRoot: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
emptyRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-live-empty-"));
|
||||||
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = emptyRoot;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (prevEnv === undefined) {
|
||||||
|
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
} else {
|
||||||
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
|
||||||
|
}
|
||||||
|
await rm(emptyRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exits 1 when no threads exist", () => {
|
||||||
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: emptyRoot };
|
||||||
|
const r = spawnSync(process.execPath, [cliEntryPath, "live", "--latest"], {
|
||||||
|
env,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(1);
|
||||||
|
expect(String(r.stderr ?? "")).toContain("no threads");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||||
|
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow";
|
||||||
|
import { resolveWorkflowStorageRoot } from "../src/storage-env.js";
|
||||||
|
|
||||||
|
describe("resolveWorkflowStorageRoot", () => {
|
||||||
|
let savedInternal: string | undefined;
|
||||||
|
let savedUser: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
savedInternal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
savedUser = process.env.WORKFLOW_STORAGE_ROOT;
|
||||||
|
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
delete process.env.WORKFLOW_STORAGE_ROOT;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (savedInternal === undefined) {
|
||||||
|
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
|
} else {
|
||||||
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = savedInternal;
|
||||||
|
}
|
||||||
|
if (savedUser === undefined) {
|
||||||
|
delete process.env.WORKFLOW_STORAGE_ROOT;
|
||||||
|
} else {
|
||||||
|
process.env.WORKFLOW_STORAGE_ROOT = savedUser;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns default when no env vars are set", () => {
|
||||||
|
expect(resolveWorkflowStorageRoot()).toBe(getDefaultWorkflowStorageRoot());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("WORKFLOW_STORAGE_ROOT overrides default", () => {
|
||||||
|
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/custom-storage";
|
||||||
|
expect(resolveWorkflowStorageRoot()).toBe("/tmp/custom-storage");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("UNCAGED_WORKFLOW_STORAGE_ROOT takes priority over WORKFLOW_STORAGE_ROOT", () => {
|
||||||
|
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/user-path";
|
||||||
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = "/tmp/internal-path";
|
||||||
|
expect(resolveWorkflowStorageRoot()).toBe("/tmp/internal-path");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores empty WORKFLOW_STORAGE_ROOT", () => {
|
||||||
|
process.env.WORKFLOW_STORAGE_ROOT = "";
|
||||||
|
expect(resolveWorkflowStorageRoot()).toBe(getDefaultWorkflowStorageRoot());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores empty UNCAGED_WORKFLOW_STORAGE_ROOT and falls through to WORKFLOW_STORAGE_ROOT", () => {
|
||||||
|
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = "";
|
||||||
|
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/user-fallback";
|
||||||
|
expect(resolveWorkflowStorageRoot()).toBe("/tmp/user-fallback");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,15 +5,14 @@ import { tmpdir } from "node:os";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { getGlobalCasDir } from "@uncaged/workflow";
|
import { getGlobalCasDir } from "@uncaged/workflow";
|
||||||
import { cmdAdd } from "../src/cmd-add.js";
|
import { cmdCasPut } from "../src/commands/cas/put.js";
|
||||||
import { cmdCasPut } from "../src/cmd-cas.js";
|
import { cmdKill, cmdPause, cmdResume } from "../src/commands/thread/control.js";
|
||||||
import { cmdKill } from "../src/cmd-kill.js";
|
import { cmdThreads } from "../src/commands/thread/list.js";
|
||||||
import { cmdPause } from "../src/cmd-pause.js";
|
import { cmdPs } from "../src/commands/thread/ps.js";
|
||||||
import { cmdPs } from "../src/cmd-ps.js";
|
import { cmdThreadRemove } from "../src/commands/thread/rm.js";
|
||||||
import { cmdResume } from "../src/cmd-resume.js";
|
import { cmdRun } from "../src/commands/thread/run.js";
|
||||||
import { cmdRun } from "../src/cmd-run.js";
|
import { cmdThreadShow } from "../src/commands/thread/show.js";
|
||||||
import { cmdThreadRemove, cmdThreadShow } from "../src/cmd-thread.js";
|
import { cmdAdd } from "../src/commands/workflow/add.js";
|
||||||
import { cmdThreads } from "../src/cmd-threads.js";
|
|
||||||
import { pathExists, readTextFileIfExists } from "../src/fs-utils.js";
|
import { pathExists, readTextFileIfExists } from "../src/fs-utils.js";
|
||||||
import { addCliArgs } from "./bundle-fixture.js";
|
import { addCliArgs } from "./bundle-fixture.js";
|
||||||
|
|
||||||
@@ -250,13 +249,16 @@ describe("cli thread commands", () => {
|
|||||||
|
|
||||||
test("cli entrypoint dispatches threads / ps (spawn)", () => {
|
test("cli entrypoint dispatches threads / ps (spawn)", () => {
|
||||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||||
const threads = spawnSync(process.execPath, [cliEntryPath, "threads"], {
|
const threads = spawnSync(process.execPath, [cliEntryPath, "thread", "list"], {
|
||||||
env,
|
env,
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
});
|
});
|
||||||
expect(threads.status).toBe(0);
|
expect(threads.status).toBe(0);
|
||||||
|
|
||||||
const ps = spawnSync(process.execPath, [cliEntryPath, "ps"], { env, encoding: "utf8" });
|
const ps = spawnSync(process.execPath, [cliEntryPath, "thread", "ps"], {
|
||||||
|
env,
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
expect(ps.status).toBe(0);
|
expect(ps.status).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -323,7 +325,7 @@ describe("cli thread commands", () => {
|
|||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter((l) => l !== "");
|
.filter((l) => l !== "");
|
||||||
expect(lines.length).toBe(2);
|
expect(lines.length).toBe(3);
|
||||||
|
|
||||||
const runningPath = join(dirname(dataPath), `${threadId}.running`);
|
const runningPath = join(dirname(dataPath), `${threadId}.running`);
|
||||||
expect(await pathExists(runningPath)).toBe(false);
|
expect(await pathExists(runningPath)).toBe(false);
|
||||||
@@ -362,8 +364,8 @@ describe("cli thread commands", () => {
|
|||||||
const resumed = await cmdResume(storageRoot, threadId);
|
const resumed = await cmdResume(storageRoot, threadId);
|
||||||
expect(resumed.ok).toBe(true);
|
expect(resumed.ok).toBe(true);
|
||||||
|
|
||||||
await waitUntilMinDataLines(dataPath, 3, 120);
|
await waitUntilMinDataLines(dataPath, 4, 120);
|
||||||
expect(await countDataJsonlLines(dataPath)).toBe(3);
|
expect(await countDataJsonlLines(dataPath)).toBe(4);
|
||||||
|
|
||||||
const runningPath = join(dirname(dataPath), `${threadId}.running`);
|
const runningPath = join(dirname(dataPath), `${threadId}.running`);
|
||||||
await waitUntilRunningFileAbsent(runningPath, 100);
|
await waitUntilRunningFileAbsent(runningPath, 100);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
"yaml": "^2.8.4"
|
"yaml": "^2.8.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "echo 'TODO'",
|
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export type DispatchFn = (storageRoot: string, argv: string[]) => Promise<number>;
|
||||||
|
|
||||||
|
export type CommandEntry = {
|
||||||
|
handler: DispatchFn;
|
||||||
|
args: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommandGroup = {
|
||||||
|
name: string;
|
||||||
|
commands: ReadonlyArray<{ name: string; args: string; description: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DispatchGroupFn = (
|
||||||
|
tableName: string,
|
||||||
|
table: Record<string, CommandEntry>,
|
||||||
|
storageRoot: string,
|
||||||
|
argv: string[],
|
||||||
|
) => Promise<number> | null;
|
||||||
@@ -1,466 +1,149 @@
|
|||||||
|
import type { CommandEntry, DispatchFn } from "./cli-command-types.js";
|
||||||
import { printCliError, printCliLine, printCliWarn } from "./cli-output.js";
|
import { printCliError, printCliLine, printCliWarn } from "./cli-output.js";
|
||||||
import { cmdAdd, formatAddSuccess, parseAddArgv } from "./cmd-add.js";
|
import { getCommandRegistry } from "./cli-registry.js";
|
||||||
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "./cmd-cas.js";
|
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
||||||
import { cmdFork, parseForkArgv } from "./cmd-fork.js";
|
import { createCasDispatcher, dispatchGc } from "./commands/cas/dispatch.js";
|
||||||
import { cmdGc } from "./cmd-gc.js";
|
import { createInitDispatcher } from "./commands/init/dispatch.js";
|
||||||
import { cmdHistory } from "./cmd-history.js";
|
import {
|
||||||
import { cmdInitTemplate, cmdInitWorkspace } from "./cmd-init.js";
|
createThreadDispatcher,
|
||||||
import { cmdKill } from "./cmd-kill.js";
|
dispatchFork,
|
||||||
import { cmdList, formatListLines } from "./cmd-list.js";
|
dispatchKill,
|
||||||
import { cmdPause } from "./cmd-pause.js";
|
dispatchLive,
|
||||||
import { cmdPs } from "./cmd-ps.js";
|
dispatchPause,
|
||||||
import { cmdRemove } from "./cmd-remove.js";
|
dispatchPs,
|
||||||
import { cmdResume } from "./cmd-resume.js";
|
dispatchResume,
|
||||||
import { cmdRollback } from "./cmd-rollback.js";
|
dispatchRun,
|
||||||
import { cmdRun } from "./cmd-run.js";
|
dispatchThreadList,
|
||||||
import { cmdShow, formatShowYaml } from "./cmd-show.js";
|
} from "./commands/thread/dispatch.js";
|
||||||
import { cmdThreadRemove, cmdThreadShow } from "./cmd-thread.js";
|
import {
|
||||||
import { cmdThreads } from "./cmd-threads.js";
|
createWorkflowDispatcher,
|
||||||
import { parseRunArgv } from "./run-argv.js";
|
dispatchAdd,
|
||||||
|
dispatchHistory,
|
||||||
|
dispatchList,
|
||||||
|
dispatchRemove,
|
||||||
|
dispatchRollback,
|
||||||
|
dispatchShow,
|
||||||
|
} from "./commands/workflow/dispatch.js";
|
||||||
|
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
|
||||||
|
|
||||||
|
export type { CommandEntry, CommandGroup, DispatchFn } from "./cli-command-types.js";
|
||||||
|
export { getCommandRegistry } from "./cli-registry.js";
|
||||||
|
|
||||||
|
function dispatchGroup(
|
||||||
|
tableName: string,
|
||||||
|
table: Record<string, CommandEntry>,
|
||||||
|
storageRoot: string,
|
||||||
|
argv: string[],
|
||||||
|
): Promise<number> | null {
|
||||||
|
const sub = argv[0];
|
||||||
|
if (sub === undefined || sub === "--help" || sub === "-h") {
|
||||||
|
const entries = Object.entries(table);
|
||||||
|
const lines = [`${tableName} subcommands:\n`];
|
||||||
|
for (const [name, e] of entries) {
|
||||||
|
const args = e.args ? ` ${e.args}` : "";
|
||||||
|
lines.push(` uncaged-workflow ${tableName} ${name}${args}`);
|
||||||
|
lines.push(` ${e.description}\n`);
|
||||||
|
}
|
||||||
|
printCliLine(lines.join("\n"));
|
||||||
|
return Promise.resolve(sub === undefined ? 1 : 0);
|
||||||
|
}
|
||||||
|
const entry = table[sub];
|
||||||
|
if (entry === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.handler(storageRoot, argv.slice(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function printDeprecation(oldCmd: string, newCmd: string): void {
|
||||||
|
printCliWarn(`⚠ "${oldCmd}" is deprecated, use "${newCmd}" instead`);
|
||||||
|
}
|
||||||
|
|
||||||
export function formatCliUsage(): string {
|
export function formatCliUsage(): string {
|
||||||
return [
|
return formatCliUsageWithGroups(getCommandRegistry(), getSkillTopics());
|
||||||
"Usage:",
|
|
||||||
" uncaged-workflow add <name> <file.esm.js> [--types <path>]",
|
|
||||||
" uncaged-workflow list",
|
|
||||||
" uncaged-workflow show <name>",
|
|
||||||
" uncaged-workflow remove <name>",
|
|
||||||
" uncaged-workflow run <name> [--prompt <text>] [--max-rounds N]",
|
|
||||||
" uncaged-workflow ps",
|
|
||||||
" uncaged-workflow kill <thread-id>",
|
|
||||||
" uncaged-workflow history <name>",
|
|
||||||
" uncaged-workflow rollback <name> [hash]",
|
|
||||||
" uncaged-workflow pause <thread-id>",
|
|
||||||
" uncaged-workflow resume <thread-id>",
|
|
||||||
" uncaged-workflow threads [name]",
|
|
||||||
" uncaged-workflow thread <id>",
|
|
||||||
" uncaged-workflow thread rm <id>",
|
|
||||||
" uncaged-workflow fork <thread-id> [--from-role <role>]",
|
|
||||||
" uncaged-workflow gc",
|
|
||||||
" uncaged-workflow cas get <thread-id> <hash>",
|
|
||||||
" uncaged-workflow cas put <thread-id> <content>",
|
|
||||||
" uncaged-workflow cas list <thread-id>",
|
|
||||||
" uncaged-workflow cas rm <thread-id> <hash>",
|
|
||||||
" uncaged-workflow init workspace <name>",
|
|
||||||
" uncaged-workflow init template <name>",
|
|
||||||
].join("\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dispatchInit(_storageRoot: string, argv: string[]): Promise<number> {
|
const dispatchWorkflow = createWorkflowDispatcher({ dispatchGroup, printDeprecation });
|
||||||
const sub = argv[0];
|
const dispatchThread = createThreadDispatcher({ dispatchGroup });
|
||||||
const name = argv[1];
|
const dispatchCas = createCasDispatcher({ dispatchGroup });
|
||||||
if (sub === undefined || name === undefined || argv.length > 2) {
|
const dispatchInit = createInitDispatcher({ dispatchGroup });
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: init requires workspace|template <name>`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sub === "workspace") {
|
async function showSkillDocOrIndex(topic: string | undefined): Promise<number> {
|
||||||
const result = await cmdInitWorkspace(process.cwd(), name);
|
if (topic === undefined) {
|
||||||
if (!result.ok) {
|
printCliLine(formatSkillIndex());
|
||||||
printCliError(result.error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
printCliLine(`initialized workflow workspace at ${result.value.rootPath}`);
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
const doc = formatSkillTopic(topic);
|
||||||
if (sub === "template") {
|
if (doc === null) {
|
||||||
const result = await cmdInitTemplate(process.cwd(), name);
|
printCliError(`unknown skill topic: ${topic}\n\n${formatSkillIndex()}`);
|
||||||
if (!result.ok) {
|
|
||||||
printCliError(result.error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
printCliLine(`initialized template at ${result.value.templatePath}`);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: unknown init subcommand: ${sub}`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchAdd(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
const parsed = parseAddArgv(argv);
|
|
||||||
if (!parsed.ok) {
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
const result = await cmdAdd(storageRoot, parsed.value);
|
printCliLine(doc);
|
||||||
if (!result.ok) {
|
|
||||||
printCliError(result.error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
for (const w of result.value.warnings) {
|
|
||||||
printCliWarn(w);
|
|
||||||
}
|
|
||||||
printCliLine(formatAddSuccess(parsed.value.name, parsed.value.filePath, result.value.hash));
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dispatchList(storageRoot: string, argv: string[]): Promise<number> {
|
async function dispatchSkill(_storageRoot: string, argv: string[]): Promise<number> {
|
||||||
if (argv.length > 0) {
|
return showSkillDocOrIndex(argv[0]);
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: list takes no arguments`);
|
}
|
||||||
return 1;
|
|
||||||
}
|
async function dispatchHelp(_storageRoot: string, argv: string[]): Promise<number> {
|
||||||
const result = await cmdList(storageRoot);
|
const skillIdx = argv.indexOf("--skill");
|
||||||
if (!result.ok) {
|
if (skillIdx !== -1) {
|
||||||
printCliError(result.error);
|
return showSkillDocOrIndex(argv[skillIdx + 1]);
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
for (const line of formatListLines(result.value)) {
|
|
||||||
printCliLine(line);
|
|
||||||
}
|
}
|
||||||
|
printCliLine(formatCliUsage());
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dispatchShow(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
const name = argv[0];
|
|
||||||
if (name === undefined || argv.length > 1) {
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: show requires <name>`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const result = await cmdShow(storageRoot, name);
|
|
||||||
if (!result.ok) {
|
|
||||||
printCliError(result.error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
printCliLine(formatShowYaml(name, result.value));
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchRemove(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
const name = argv[0];
|
|
||||||
if (name === undefined || argv.length > 1) {
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: remove requires <name>`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const result = await cmdRemove(storageRoot, name);
|
|
||||||
if (!result.ok) {
|
|
||||||
printCliError(result.error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
printCliLine(`removed workflow "${name}" from registry`);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchRun(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
const parsed = parseRunArgv(argv);
|
|
||||||
if (!parsed.ok) {
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await cmdRun(
|
|
||||||
storageRoot,
|
|
||||||
parsed.value.name,
|
|
||||||
parsed.value.prompt,
|
|
||||||
parsed.value.maxRounds,
|
|
||||||
);
|
|
||||||
if (!result.ok) {
|
|
||||||
printCliError(result.error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
printCliLine(result.value.threadId);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchPs(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
if (argv.length > 0) {
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: ps takes no arguments`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
for (const line of await cmdPs(storageRoot)) {
|
|
||||||
printCliLine(line);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchKill(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
const threadId = argv[0];
|
|
||||||
if (threadId === undefined || argv.length > 1) {
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: kill requires <thread-id>`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const result = await cmdKill(storageRoot, threadId);
|
|
||||||
if (!result.ok) {
|
|
||||||
printCliError(result.error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
printCliLine(`kill sent for thread ${threadId}`);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchHistory(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
const name = argv[0];
|
|
||||||
if (name === undefined || argv.length > 1) {
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: history requires <name>`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const result = await cmdHistory(storageRoot, name);
|
|
||||||
if (!result.ok) {
|
|
||||||
printCliError(result.error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
for (const line of result.value) {
|
|
||||||
printCliLine(line);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchRollback(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
const name = argv[0];
|
|
||||||
if (name === undefined || argv.length > 2) {
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: rollback requires <name> [hash]`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const hashArg = argv[1];
|
|
||||||
const result = await cmdRollback(storageRoot, name, hashArg === undefined ? null : hashArg);
|
|
||||||
if (!result.ok) {
|
|
||||||
printCliError(result.error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
printCliLine(`rolled back workflow "${name}"`);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchPause(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
const threadId = argv[0];
|
|
||||||
if (threadId === undefined || argv.length > 1) {
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: pause requires <thread-id>`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const result = await cmdPause(storageRoot, threadId);
|
|
||||||
if (!result.ok) {
|
|
||||||
printCliError(result.error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
printCliLine(`pause sent for thread ${threadId}`);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchResume(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
const threadId = argv[0];
|
|
||||||
if (threadId === undefined || argv.length > 1) {
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: resume requires <thread-id>`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const result = await cmdResume(storageRoot, threadId);
|
|
||||||
if (!result.ok) {
|
|
||||||
printCliError(result.error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
printCliLine(`resume sent for thread ${threadId}`);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchThreads(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
const result = await cmdThreads(storageRoot, argv);
|
|
||||||
if (!result.ok) {
|
|
||||||
printCliError(result.error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
for (const line of result.value) {
|
|
||||||
printCliLine(line);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchThread(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
const id = argv[0];
|
|
||||||
if (id === undefined || argv.length > 1) {
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: thread requires <id>`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const result = await cmdThreadShow(storageRoot, id);
|
|
||||||
if (!result.ok) {
|
|
||||||
printCliError(result.error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
printCliLine(result.value);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchThreadRm(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
const id = argv[0];
|
|
||||||
if (id === undefined || argv.length > 1) {
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: thread rm requires <id>`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const result = await cmdThreadRemove(storageRoot, id);
|
|
||||||
if (!result.ok) {
|
|
||||||
printCliError(result.error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
printCliLine(`removed thread ${id}`);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchThreadBranch(storageRoot: string, rest: string[]): Promise<number> {
|
|
||||||
const sub = rest[0];
|
|
||||||
if (sub === "rm") {
|
|
||||||
return dispatchThreadRm(storageRoot, rest.slice(1));
|
|
||||||
}
|
|
||||||
return dispatchThread(storageRoot, rest);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchGc(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
if (argv.length > 0) {
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: gc takes no arguments`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const result = await cmdGc(storageRoot);
|
|
||||||
if (!result.ok) {
|
|
||||||
printCliError(result.error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const stats = result.value;
|
|
||||||
printCliLine(
|
|
||||||
`scanned ${stats.scannedThreads} threads, ${stats.activeRefs} active refs, deleted ${stats.deletedEntries} entries`,
|
|
||||||
);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchFork(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
const parsed = parseForkArgv(argv);
|
|
||||||
if (!parsed.ok) {
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const result = await cmdFork(storageRoot, parsed.value.threadId, parsed.value.fromRole);
|
|
||||||
if (!result.ok) {
|
|
||||||
printCliError(result.error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
printCliLine(result.value.threadId);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchCasGet(storageRoot: string, rest: string[]): Promise<number> {
|
|
||||||
const threadId = rest[0];
|
|
||||||
const hash = rest[1];
|
|
||||||
if (threadId === undefined || hash === undefined || rest.length > 2) {
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: cas get requires <thread-id> <hash>`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const result = await cmdCasGet(storageRoot, threadId, hash);
|
|
||||||
if (!result.ok) {
|
|
||||||
printCliError(result.error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
printCliLine(result.value);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchCasPut(storageRoot: string, rest: string[]): Promise<number> {
|
|
||||||
const threadId = rest[0];
|
|
||||||
const content = rest[1];
|
|
||||||
if (threadId === undefined || content === undefined || rest.length > 2) {
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: cas put requires <thread-id> <content>`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const result = await cmdCasPut(storageRoot, threadId, content);
|
|
||||||
if (!result.ok) {
|
|
||||||
printCliError(result.error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
printCliLine(result.value);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchCasList(storageRoot: string, rest: string[]): Promise<number> {
|
|
||||||
const threadId = rest[0];
|
|
||||||
if (threadId === undefined || rest.length > 1) {
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: cas list requires <thread-id>`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const result = await cmdCasList(storageRoot, threadId);
|
|
||||||
if (!result.ok) {
|
|
||||||
printCliError(result.error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
for (const hash of result.value) {
|
|
||||||
printCliLine(hash);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispatchCasRm(storageRoot: string, rest: string[]): Promise<number> {
|
|
||||||
const threadId = rest[0];
|
|
||||||
const hash = rest[1];
|
|
||||||
if (threadId === undefined || hash === undefined || rest.length > 2) {
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: cas rm requires <thread-id> <hash>`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const result = await cmdCasRm(storageRoot, threadId, hash);
|
|
||||||
if (!result.ok) {
|
|
||||||
printCliError(result.error);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
printCliLine(`removed cas entry ${hash}`);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CAS_SUBCOMMAND_TABLE: Record<
|
|
||||||
string,
|
|
||||||
(storageRoot: string, rest: string[]) => Promise<number>
|
|
||||||
> = {
|
|
||||||
get: dispatchCasGet,
|
|
||||||
put: dispatchCasPut,
|
|
||||||
list: dispatchCasList,
|
|
||||||
rm: dispatchCasRm,
|
|
||||||
};
|
|
||||||
|
|
||||||
async function dispatchCas(storageRoot: string, argv: string[]): Promise<number> {
|
|
||||||
const sub = argv[0];
|
|
||||||
if (sub === undefined) {
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: unknown cas subcommand: (none)`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const handler = CAS_SUBCOMMAND_TABLE[sub];
|
|
||||||
if (handler === undefined) {
|
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: unknown cas subcommand: ${sub}`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return handler(storageRoot, argv.slice(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
type DispatchFn = (storageRoot: string, argv: string[]) => Promise<number>;
|
|
||||||
|
|
||||||
const COMMAND_TABLE: Record<string, DispatchFn> = {
|
const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||||
add: dispatchAdd,
|
workflow: dispatchWorkflow,
|
||||||
init: dispatchInit,
|
thread: dispatchThread,
|
||||||
list: dispatchList,
|
|
||||||
show: dispatchShow,
|
|
||||||
remove: dispatchRemove,
|
|
||||||
run: dispatchRun,
|
|
||||||
ps: dispatchPs,
|
|
||||||
kill: dispatchKill,
|
|
||||||
history: dispatchHistory,
|
|
||||||
rollback: dispatchRollback,
|
|
||||||
pause: dispatchPause,
|
|
||||||
resume: dispatchResume,
|
|
||||||
threads: dispatchThreads,
|
|
||||||
thread: dispatchThreadBranch,
|
|
||||||
fork: dispatchFork,
|
|
||||||
gc: dispatchGc,
|
|
||||||
cas: dispatchCas,
|
cas: dispatchCas,
|
||||||
|
init: dispatchInit,
|
||||||
|
help: dispatchHelp,
|
||||||
|
skill: dispatchSkill,
|
||||||
|
run: dispatchRun,
|
||||||
|
live: dispatchLive,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEPRECATED_ALIASES: Record<string, { newCmd: string; handler: DispatchFn }> = {
|
||||||
|
add: { newCmd: "workflow add", handler: dispatchAdd },
|
||||||
|
list: { newCmd: "workflow list", handler: dispatchList },
|
||||||
|
show: { newCmd: "workflow show", handler: dispatchShow },
|
||||||
|
remove: { newCmd: "workflow rm", handler: dispatchRemove },
|
||||||
|
ps: { newCmd: "thread ps", handler: dispatchPs },
|
||||||
|
kill: { newCmd: "thread kill", handler: dispatchKill },
|
||||||
|
pause: { newCmd: "thread pause", handler: dispatchPause },
|
||||||
|
resume: { newCmd: "thread resume", handler: dispatchResume },
|
||||||
|
threads: { newCmd: "thread list", handler: dispatchThreadList },
|
||||||
|
fork: { newCmd: "thread fork", handler: dispatchFork },
|
||||||
|
gc: { newCmd: "cas gc", handler: dispatchGc },
|
||||||
|
history: { newCmd: "workflow history", handler: dispatchHistory },
|
||||||
|
rollback: { newCmd: "workflow rollback", handler: dispatchRollback },
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
if (argv.length === 0) {
|
if (argv.length === 0) {
|
||||||
printCliError(formatCliUsage());
|
printCliLine(formatCliUsage());
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
const command = argv[0];
|
const command = argv[0];
|
||||||
if (command === undefined) {
|
if (command === undefined) {
|
||||||
printCliError(formatCliUsage());
|
printCliLine(formatCliUsage());
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
const rest = argv.slice(1);
|
const rest = argv.slice(1);
|
||||||
|
|
||||||
const dispatch = COMMAND_TABLE[command];
|
const dispatch = COMMAND_TABLE[command];
|
||||||
if (dispatch === undefined) {
|
if (dispatch !== undefined) {
|
||||||
printCliError(`${formatCliUsage()}\n\nerror: unknown command ${command}`);
|
return dispatch(storageRoot, rest);
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
return dispatch(storageRoot, rest);
|
|
||||||
|
const deprecated = DEPRECATED_ALIASES[command];
|
||||||
|
if (deprecated !== undefined) {
|
||||||
|
printDeprecation(command, deprecated.newCmd);
|
||||||
|
return deprecated.handler(storageRoot, rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
printCliError(`${formatCliUsage()}\n\nerror: unknown command ${command}`);
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { CommandGroup } from "./cli-command-types.js";
|
||||||
|
import { setCommandGroupsForUsage } from "./cli-usage-context.js";
|
||||||
|
import { CAS_SUBCOMMAND_TABLE } from "./commands/cas/dispatch.js";
|
||||||
|
import { INIT_SUBCOMMAND_TABLE } from "./commands/init/dispatch.js";
|
||||||
|
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/dispatch.js";
|
||||||
|
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/dispatch.js";
|
||||||
|
|
||||||
|
export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "workflow",
|
||||||
|
commands: Object.entries(WORKFLOW_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||||
|
name,
|
||||||
|
args: e.args,
|
||||||
|
description: e.description,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "thread",
|
||||||
|
commands: Object.entries(THREAD_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||||
|
name,
|
||||||
|
args: e.args,
|
||||||
|
description: e.description,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cas",
|
||||||
|
commands: Object.entries(CAS_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||||
|
name,
|
||||||
|
args: e.args,
|
||||||
|
description: e.description,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "init",
|
||||||
|
commands: Object.entries(INIT_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||||
|
name,
|
||||||
|
args: e.args,
|
||||||
|
description: e.description,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
setCommandGroupsForUsage(getCommandRegistry());
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { CommandGroup } from "./cli-command-types.js";
|
||||||
|
|
||||||
|
let commandGroupsForUsage: ReadonlyArray<CommandGroup> | null = null;
|
||||||
|
|
||||||
|
export function setCommandGroupsForUsage(groups: ReadonlyArray<CommandGroup>): void {
|
||||||
|
commandGroupsForUsage = groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCommandGroupsForUsage(): ReadonlyArray<CommandGroup> {
|
||||||
|
if (commandGroupsForUsage === null) {
|
||||||
|
throw new Error("BUG: command groups for usage not initialized");
|
||||||
|
}
|
||||||
|
return commandGroupsForUsage;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { CommandGroup } from "./cli-command-types.js";
|
||||||
|
|
||||||
|
/** Keep aligned with `getSkillTopics()` in skill.ts (names only, for error usage lines). */
|
||||||
|
export const USAGE_SKILL_TOPIC_ROWS: ReadonlyArray<{ name: string }> = [
|
||||||
|
{ name: "cli" },
|
||||||
|
{ name: "develop" },
|
||||||
|
{ name: "author" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const USAGE_SECTION_BY_GROUP: Record<string, string> = {
|
||||||
|
workflow: "Workflow registry:",
|
||||||
|
thread: "Thread execution:",
|
||||||
|
cas: "Content-addressable storage:",
|
||||||
|
init: "Development:",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatUsageCommandLines(
|
||||||
|
rows: ReadonlyArray<{ prefix: string; description: string }>,
|
||||||
|
): string[] {
|
||||||
|
const maxPrefix = rows.reduce((max, row) => Math.max(max, row.prefix.length), 0);
|
||||||
|
const gap = 2;
|
||||||
|
return rows.map((row) => {
|
||||||
|
const pad = " ".repeat(maxPrefix - row.prefix.length + gap);
|
||||||
|
return ` ${row.prefix}${pad}${row.description}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCliUsage(
|
||||||
|
groups: ReadonlyArray<CommandGroup>,
|
||||||
|
skillTopics: ReadonlyArray<{ name: string }>,
|
||||||
|
): string {
|
||||||
|
const lines: string[] = ["uncaged-workflow — workflow engine CLI", ""];
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const sectionTitle = USAGE_SECTION_BY_GROUP[group.name];
|
||||||
|
if (sectionTitle === undefined) {
|
||||||
|
throw new Error(`BUG: missing usage section title for group "${group.name}"`);
|
||||||
|
}
|
||||||
|
lines.push(sectionTitle);
|
||||||
|
const rows = group.commands.map((cmd) => {
|
||||||
|
const args = cmd.args ? ` ${cmd.args}` : "";
|
||||||
|
return {
|
||||||
|
prefix: `${group.name} ${cmd.name}${args}`,
|
||||||
|
description: cmd.description,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
lines.push(...formatUsageCommandLines(rows));
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("Shortcuts:");
|
||||||
|
lines.push(
|
||||||
|
...formatUsageCommandLines([
|
||||||
|
{ prefix: "run <name> [...]", description: "→ thread run" },
|
||||||
|
{ prefix: "live <id> [...]", description: "→ thread live" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("Reference:");
|
||||||
|
const skillTopicNames = skillTopics.map((t) => t.name).join(", ");
|
||||||
|
lines.push(
|
||||||
|
...formatUsageCommandLines([
|
||||||
|
{
|
||||||
|
prefix: "skill [topic]",
|
||||||
|
description: `Agent-consumable docs (${skillTopicNames})`,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Use <command> --help for subcommand details.");
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Environment variables:");
|
||||||
|
lines.push(
|
||||||
|
" WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)",
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
" UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)",
|
||||||
|
);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { createCasStore, err, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
|
|
||||||
|
|
||||||
export async function cmdCasGet(
|
|
||||||
storageRoot: string,
|
|
||||||
_threadId: string,
|
|
||||||
hash: string,
|
|
||||||
): Promise<Result<string, string>> {
|
|
||||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
|
||||||
const content = await cas.get(hash);
|
|
||||||
if (content === null) {
|
|
||||||
return err(`cas entry not found: ${hash}`);
|
|
||||||
}
|
|
||||||
return ok(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cmdCasPut(
|
|
||||||
storageRoot: string,
|
|
||||||
_threadId: string,
|
|
||||||
content: string,
|
|
||||||
): Promise<Result<string, string>> {
|
|
||||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
|
||||||
const hash = await cas.put(content);
|
|
||||||
return ok(hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cmdCasList(
|
|
||||||
storageRoot: string,
|
|
||||||
_threadId: string,
|
|
||||||
): Promise<Result<string[], string>> {
|
|
||||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
|
||||||
const hashes = await cas.list();
|
|
||||||
return ok(hashes);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cmdCasRm(
|
|
||||||
storageRoot: string,
|
|
||||||
_threadId: string,
|
|
||||||
hash: string,
|
|
||||||
): Promise<Result<void, string>> {
|
|
||||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
|
||||||
await cas.delete(hash);
|
|
||||||
return ok(undefined);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
import { err, type Result } from "@uncaged/workflow";
|
|
||||||
|
|
||||||
import { readTextFileIfExists } from "./fs-utils.js";
|
|
||||||
import {
|
|
||||||
resolveRunningHashForThread,
|
|
||||||
sendWorkerTcpCommand,
|
|
||||||
type WorkerCtl,
|
|
||||||
} from "./worker-spawn.js";
|
|
||||||
|
|
||||||
export async function cmdKill(
|
|
||||||
storageRoot: string,
|
|
||||||
threadId: string,
|
|
||||||
): Promise<Result<void, string>> {
|
|
||||||
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
|
|
||||||
if (!hashResult.ok) {
|
|
||||||
return hashResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctlPath = join(storageRoot, "workers", `${hashResult.value}.json`);
|
|
||||||
const ctlText = await readTextFileIfExists(ctlPath);
|
|
||||||
if (ctlText === null) {
|
|
||||||
return err(`worker control file missing for bundle hash ${hashResult.value}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ctl: WorkerCtl;
|
|
||||||
try {
|
|
||||||
ctl = JSON.parse(ctlText) as WorkerCtl;
|
|
||||||
} catch {
|
|
||||||
return err(`corrupt worker control file: ${ctlPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof ctl.port !== "number" || ctl.port <= 0) {
|
|
||||||
return err(`invalid worker control file: ${ctlPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await sendWorkerTcpCommand(
|
|
||||||
ctl.port,
|
|
||||||
{ type: "kill", threadId },
|
|
||||||
{ awaitResponseLine: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
import { err, type Result } from "@uncaged/workflow";
|
|
||||||
|
|
||||||
import { readTextFileIfExists } from "./fs-utils.js";
|
|
||||||
import {
|
|
||||||
resolveRunningHashForThread,
|
|
||||||
sendWorkerTcpCommand,
|
|
||||||
type WorkerCtl,
|
|
||||||
} from "./worker-spawn.js";
|
|
||||||
|
|
||||||
export async function cmdPause(
|
|
||||||
storageRoot: string,
|
|
||||||
threadId: string,
|
|
||||||
): Promise<Result<void, string>> {
|
|
||||||
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
|
|
||||||
if (!hashResult.ok) {
|
|
||||||
return hashResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctlPath = join(storageRoot, "workers", `${hashResult.value}.json`);
|
|
||||||
const ctlText = await readTextFileIfExists(ctlPath);
|
|
||||||
if (ctlText === null) {
|
|
||||||
return err(`worker control file missing for bundle hash ${hashResult.value}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ctl: WorkerCtl;
|
|
||||||
try {
|
|
||||||
ctl = JSON.parse(ctlText) as WorkerCtl;
|
|
||||||
} catch {
|
|
||||||
return err(`corrupt worker control file: ${ctlPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof ctl.port !== "number" || ctl.port <= 0) {
|
|
||||||
return err(`invalid worker control file: ${ctlPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await sendWorkerTcpCommand(
|
|
||||||
ctl.port,
|
|
||||||
{ type: "pause", threadId },
|
|
||||||
{ awaitResponseLine: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
import { err, type Result } from "@uncaged/workflow";
|
|
||||||
|
|
||||||
import { readTextFileIfExists } from "./fs-utils.js";
|
|
||||||
import {
|
|
||||||
resolveRunningHashForThread,
|
|
||||||
sendWorkerTcpCommand,
|
|
||||||
type WorkerCtl,
|
|
||||||
} from "./worker-spawn.js";
|
|
||||||
|
|
||||||
export async function cmdResume(
|
|
||||||
storageRoot: string,
|
|
||||||
threadId: string,
|
|
||||||
): Promise<Result<void, string>> {
|
|
||||||
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
|
|
||||||
if (!hashResult.ok) {
|
|
||||||
return hashResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctlPath = join(storageRoot, "workers", `${hashResult.value}.json`);
|
|
||||||
const ctlText = await readTextFileIfExists(ctlPath);
|
|
||||||
if (ctlText === null) {
|
|
||||||
return err(`worker control file missing for bundle hash ${hashResult.value}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ctl: WorkerCtl;
|
|
||||||
try {
|
|
||||||
ctl = JSON.parse(ctlText) as WorkerCtl;
|
|
||||||
} catch {
|
|
||||||
return err(`corrupt worker control file: ${ctlPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof ctl.port !== "number" || ctl.port <= 0) {
|
|
||||||
return err(`invalid worker control file: ${ctlPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await sendWorkerTcpCommand(
|
|
||||||
ctl.port,
|
|
||||||
{ type: "resume", threadId },
|
|
||||||
{ awaitResponseLine: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { CommandEntry, DispatchGroupFn } from "../../cli-command-types.js";
|
||||||
|
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||||
|
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
|
||||||
|
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
|
||||||
|
import { cmdGc } from "./gc.js";
|
||||||
|
import { cmdCasGet } from "./get.js";
|
||||||
|
import { cmdCasList } from "./list.js";
|
||||||
|
import { cmdCasPut } from "./put.js";
|
||||||
|
import { cmdCasRm } from "./rm.js";
|
||||||
|
|
||||||
|
function usageText(): string {
|
||||||
|
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchGc(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
if (argv.length > 0) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: gc takes no arguments`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdGc(storageRoot);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const stats = result.value;
|
||||||
|
printCliLine(
|
||||||
|
`scanned ${stats.scannedThreads} threads, ${stats.activeRefs} active refs, deleted ${stats.deletedEntries} entries`,
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchCasGet(storageRoot: string, rest: string[]): Promise<number> {
|
||||||
|
const threadId = rest[0];
|
||||||
|
const hash = rest[1];
|
||||||
|
if (threadId === undefined || hash === undefined || rest.length > 2) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: cas get requires <thread-id> <hash>`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdCasGet(storageRoot, threadId, hash);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printCliLine(result.value);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchCasPut(storageRoot: string, rest: string[]): Promise<number> {
|
||||||
|
const threadId = rest[0];
|
||||||
|
const content = rest[1];
|
||||||
|
if (threadId === undefined || content === undefined || rest.length > 2) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: cas put requires <thread-id> <content>`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdCasPut(storageRoot, threadId, content);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printCliLine(result.value);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchCasList(storageRoot: string, rest: string[]): Promise<number> {
|
||||||
|
const threadId = rest[0];
|
||||||
|
if (threadId === undefined || rest.length > 1) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: cas list requires <thread-id>`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdCasList(storageRoot, threadId);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
for (const hash of result.value) {
|
||||||
|
printCliLine(hash);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchCasRm(storageRoot: string, rest: string[]): Promise<number> {
|
||||||
|
const threadId = rest[0];
|
||||||
|
const hash = rest[1];
|
||||||
|
if (threadId === undefined || hash === undefined || rest.length > 2) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: cas rm requires <thread-id> <hash>`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdCasRm(storageRoot, threadId, hash);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printCliLine(`removed cas entry ${hash}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CAS_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||||
|
get: {
|
||||||
|
handler: dispatchCasGet,
|
||||||
|
args: "<thread-id> <hash>",
|
||||||
|
description: "Retrieve content by hash from a thread's CAS",
|
||||||
|
},
|
||||||
|
put: {
|
||||||
|
handler: dispatchCasPut,
|
||||||
|
args: "<thread-id> <content>",
|
||||||
|
description: "Store content in a thread's CAS, returns hash",
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
handler: dispatchCasList,
|
||||||
|
args: "<thread-id>",
|
||||||
|
description: "List all CAS entries for a thread",
|
||||||
|
},
|
||||||
|
rm: { handler: dispatchCasRm, args: "<thread-id> <hash>", description: "Remove a CAS entry" },
|
||||||
|
gc: { handler: dispatchGc, args: "", description: "Garbage-collect unreferenced CAS entries" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createCasDispatcher(deps: { dispatchGroup: DispatchGroupFn }) {
|
||||||
|
const { dispatchGroup } = deps;
|
||||||
|
return async function dispatchCas(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const result = dispatchGroup("cas", CAS_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||||
|
if (result !== null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const sub = argv[0];
|
||||||
|
printCliError(`${usageText()}\n\nerror: unknown cas subcommand: ${sub}`);
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { createCasStore, err, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
export async function cmdCasGet(
|
||||||
|
storageRoot: string,
|
||||||
|
_threadId: string,
|
||||||
|
hash: string,
|
||||||
|
): Promise<Result<string, string>> {
|
||||||
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||||
|
const content = await cas.get(hash);
|
||||||
|
if (content === null) {
|
||||||
|
return err(`cas entry not found: ${hash}`);
|
||||||
|
}
|
||||||
|
return ok(content);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { cmdGc } from "./gc.js";
|
||||||
|
export { cmdCasGet } from "./get.js";
|
||||||
|
export { cmdCasList } from "./list.js";
|
||||||
|
export { cmdCasPut } from "./put.js";
|
||||||
|
export { cmdCasRm } from "./rm.js";
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
export async function cmdCasList(
|
||||||
|
storageRoot: string,
|
||||||
|
_threadId: string,
|
||||||
|
): Promise<Result<string[], string>> {
|
||||||
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||||
|
const hashes = await cas.list();
|
||||||
|
return ok(hashes);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
export async function cmdCasPut(
|
||||||
|
storageRoot: string,
|
||||||
|
_threadId: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<Result<string, string>> {
|
||||||
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||||
|
const hash = await cas.put(content);
|
||||||
|
return ok(hash);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
export async function cmdCasRm(
|
||||||
|
storageRoot: string,
|
||||||
|
_threadId: string,
|
||||||
|
hash: string,
|
||||||
|
): Promise<Result<void, string>> {
|
||||||
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||||
|
await cas.delete(hash);
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { CommandEntry, DispatchGroupFn } from "../../cli-command-types.js";
|
||||||
|
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||||
|
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
|
||||||
|
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
|
||||||
|
import { cmdInitTemplate } from "./template.js";
|
||||||
|
import { cmdInitWorkspace } from "./workspace.js";
|
||||||
|
|
||||||
|
function usageText(): string {
|
||||||
|
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchInitWorkspace(_storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const name = argv[0];
|
||||||
|
if (name === undefined || argv.length > 1) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: init workspace requires <name>`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdInitWorkspace(process.cwd(), name);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printCliLine(`initialized workflow workspace at ${result.value.rootPath}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchInitTemplate(_storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const name = argv[0];
|
||||||
|
if (name === undefined || argv.length > 1) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: init template requires <name>`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdInitTemplate(process.cwd(), name);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printCliLine(`initialized template at ${result.value.templatePath}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INIT_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||||
|
workspace: {
|
||||||
|
handler: dispatchInitWorkspace,
|
||||||
|
args: "<name>",
|
||||||
|
description: "Initialize a new workflow workspace",
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
handler: dispatchInitTemplate,
|
||||||
|
args: "<name>",
|
||||||
|
description: "Initialize a new workflow template",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createInitDispatcher(deps: { dispatchGroup: DispatchGroupFn }) {
|
||||||
|
const { dispatchGroup } = deps;
|
||||||
|
return async function dispatchInit(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const result = dispatchGroup("init", INIT_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||||
|
if (result !== null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const sub = argv[0];
|
||||||
|
printCliError(`${usageText()}\n\nerror: unknown init subcommand: ${sub}`);
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type { CmdInitTemplateSuccess } from "./template.js";
|
||||||
|
export { cmdInitTemplate } from "./template.js";
|
||||||
|
export type { CmdInitWorkspaceSuccess } from "./workspace.js";
|
||||||
|
export { cmdInitWorkspace } from "./workspace.js";
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { err, ok, type Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
import { pathExists } from "../../fs-utils.js";
|
||||||
|
|
||||||
|
export type CmdInitTemplateSuccess = {
|
||||||
|
templatePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function validateWorkspaceSegment(name: string): Result<void, string> {
|
||||||
|
if (name.length === 0) {
|
||||||
|
return err("workspace name must not be empty");
|
||||||
|
}
|
||||||
|
if (name === "." || name === "..") {
|
||||||
|
return err("invalid workspace name");
|
||||||
|
}
|
||||||
|
if (name.includes("/") || name.includes("\\")) {
|
||||||
|
return err("workspace name must not contain path separators");
|
||||||
|
}
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean {
|
||||||
|
return Array.isArray(workspaces) && workspaces.includes("templates/*");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPackageJsonWorkspaces(dir: string): Promise<unknown | null> {
|
||||||
|
const pkgPath = join(dir, "package.json");
|
||||||
|
if (!(await pathExists(pkgPath))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await readFile(pkgPath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof parsed !== "object" || parsed === null || !("workspaces" in parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (parsed as { workspaces: unknown }).workspaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve uncaged-workflow workspace root (package.json with `templates/*` in `workspaces`). */
|
||||||
|
async function findWorkflowWorkspaceRoot(startDir: string): Promise<Result<string, string>> {
|
||||||
|
let dir = resolve(startDir);
|
||||||
|
for (;;) {
|
||||||
|
const workspaces = await readPackageJsonWorkspaces(dir);
|
||||||
|
if (workspaces !== null && hasTemplatesWorkspaceGlob(workspaces)) {
|
||||||
|
return ok(dir);
|
||||||
|
}
|
||||||
|
const parent = dirname(dir);
|
||||||
|
if (parent === dir) {
|
||||||
|
return err(
|
||||||
|
'not inside a workflow workspace (no package.json with workspaces containing "templates/*")',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function templatePackageJson(templateName: string): string {
|
||||||
|
return `${JSON.stringify(
|
||||||
|
{
|
||||||
|
name: `template-${templateName}`,
|
||||||
|
version: "0.0.0",
|
||||||
|
private: true,
|
||||||
|
type: "module",
|
||||||
|
dependencies: {
|
||||||
|
"@uncaged/workflow": "^0.1.0",
|
||||||
|
zod: "^4.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateTsconfigJson(): string {
|
||||||
|
return `${JSON.stringify(
|
||||||
|
{
|
||||||
|
extends: "../../tsconfig.json",
|
||||||
|
compilerOptions: {
|
||||||
|
rootDir: "src",
|
||||||
|
outDir: "dist",
|
||||||
|
},
|
||||||
|
include: ["src/**/*.ts"],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateRolesTs(): string {
|
||||||
|
return `import type { RoleDefinition } from "@uncaged/workflow";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
export const HELLO_TEMPLATE_DESCRIPTION =
|
||||||
|
"Minimal starter template: one greeter role, then END.";
|
||||||
|
|
||||||
|
export type HelloTemplateMeta = {
|
||||||
|
greeter: {
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const greeterMetaSchema = z.object({
|
||||||
|
message: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
||||||
|
description: "Says hello — replace with your first role.",
|
||||||
|
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
|
||||||
|
extractPrompt: "Extract the assistant's greeting as message.",
|
||||||
|
schema: greeterMetaSchema,
|
||||||
|
extractRefs: null,
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateModeratorTs(): string {
|
||||||
|
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
import type { HelloTemplateMeta } from "./roles.js";
|
||||||
|
|
||||||
|
export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
|
||||||
|
ctx: ModeratorContext<HelloTemplateMeta>,
|
||||||
|
) => {
|
||||||
|
if (ctx.steps.length === 0) {
|
||||||
|
return "greeter";
|
||||||
|
}
|
||||||
|
return END;
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateIndexTs(): string {
|
||||||
|
return `import type { WorkflowDefinition } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
import { helloTemplateModerator } from "./moderator.js";
|
||||||
|
import {
|
||||||
|
HELLO_TEMPLATE_DESCRIPTION,
|
||||||
|
type HelloTemplateMeta,
|
||||||
|
greeterRole,
|
||||||
|
} from "./roles.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
HELLO_TEMPLATE_DESCRIPTION,
|
||||||
|
type HelloTemplateMeta,
|
||||||
|
greeterRole,
|
||||||
|
} from "./roles.js";
|
||||||
|
export { helloTemplateModerator } from "./moderator.js";
|
||||||
|
|
||||||
|
export const helloTemplateWorkflowDefinition: WorkflowDefinition<HelloTemplateMeta> = {
|
||||||
|
description: HELLO_TEMPLATE_DESCRIPTION,
|
||||||
|
roles: {
|
||||||
|
greeter: greeterRole,
|
||||||
|
},
|
||||||
|
moderator: helloTemplateModerator,
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdInitTemplate(
|
||||||
|
startDir: string,
|
||||||
|
templateName: string,
|
||||||
|
): Promise<Result<CmdInitTemplateSuccess, string>> {
|
||||||
|
const validated = validateWorkspaceSegment(templateName);
|
||||||
|
if (!validated.ok) {
|
||||||
|
return validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootResult = await findWorkflowWorkspaceRoot(startDir);
|
||||||
|
if (!rootResult.ok) {
|
||||||
|
return rootResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceRoot = rootResult.value;
|
||||||
|
const templateDir = join(workspaceRoot, "templates", templateName);
|
||||||
|
if (await pathExists(templateDir)) {
|
||||||
|
return err(`template already exists: ${templateDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(join(templateDir, "src"), { recursive: true });
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(join(templateDir, "package.json"), templatePackageJson(templateName), "utf8"),
|
||||||
|
writeFile(join(templateDir, "tsconfig.json"), templateTsconfigJson(), "utf8"),
|
||||||
|
writeFile(join(templateDir, "src", "roles.ts"), templateRolesTs(), "utf8"),
|
||||||
|
writeFile(join(templateDir, "src", "moderator.ts"), templateModeratorTs(), "utf8"),
|
||||||
|
writeFile(join(templateDir, "src", "index.ts"), templateIndexTs(), "utf8"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ok({ templatePath: templateDir });
|
||||||
|
}
|
||||||
+3
-187
@@ -1,18 +1,14 @@
|
|||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
import { dirname, join, resolve } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { err, ok, type Result } from "@uncaged/workflow";
|
import { err, ok, type Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
import { pathExists } from "./fs-utils.js";
|
import { pathExists } from "../../fs-utils.js";
|
||||||
|
|
||||||
export type CmdInitWorkspaceSuccess = {
|
export type CmdInitWorkspaceSuccess = {
|
||||||
rootPath: string;
|
rootPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CmdInitTemplateSuccess = {
|
|
||||||
templatePath: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function validateWorkspaceSegment(name: string): Result<void, string> {
|
function validateWorkspaceSegment(name: string): Result<void, string> {
|
||||||
if (name.length === 0) {
|
if (name.length === 0) {
|
||||||
return err("workspace name must not be empty");
|
return err("workspace name must not be empty");
|
||||||
@@ -233,183 +229,3 @@ export async function cmdInitWorkspace(
|
|||||||
|
|
||||||
return ok({ rootPath });
|
return ok({ rootPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean {
|
|
||||||
return Array.isArray(workspaces) && workspaces.includes("templates/*");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readPackageJsonWorkspaces(dir: string): Promise<unknown | null> {
|
|
||||||
const pkgPath = join(dir, "package.json");
|
|
||||||
if (!(await pathExists(pkgPath))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let raw: string;
|
|
||||||
try {
|
|
||||||
raw = await readFile(pkgPath, "utf8");
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(raw) as unknown;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (typeof parsed !== "object" || parsed === null || !("workspaces" in parsed)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (parsed as { workspaces: unknown }).workspaces;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve uncaged-workflow workspace root (package.json with `templates/*` in `workspaces`). */
|
|
||||||
async function findWorkflowWorkspaceRoot(startDir: string): Promise<Result<string, string>> {
|
|
||||||
let dir = resolve(startDir);
|
|
||||||
for (;;) {
|
|
||||||
const workspaces = await readPackageJsonWorkspaces(dir);
|
|
||||||
if (workspaces !== null && hasTemplatesWorkspaceGlob(workspaces)) {
|
|
||||||
return ok(dir);
|
|
||||||
}
|
|
||||||
const parent = dirname(dir);
|
|
||||||
if (parent === dir) {
|
|
||||||
return err(
|
|
||||||
'not inside a workflow workspace (no package.json with workspaces containing "templates/*")',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
dir = parent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function templatePackageJson(templateName: string): string {
|
|
||||||
return `${JSON.stringify(
|
|
||||||
{
|
|
||||||
name: `template-${templateName}`,
|
|
||||||
version: "0.0.0",
|
|
||||||
private: true,
|
|
||||||
type: "module",
|
|
||||||
dependencies: {
|
|
||||||
"@uncaged/workflow": "^0.1.0",
|
|
||||||
zod: "^4.0.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function templateTsconfigJson(): string {
|
|
||||||
return `${JSON.stringify(
|
|
||||||
{
|
|
||||||
extends: "../../tsconfig.json",
|
|
||||||
compilerOptions: {
|
|
||||||
rootDir: "src",
|
|
||||||
outDir: "dist",
|
|
||||||
},
|
|
||||||
include: ["src/**/*.ts"],
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function templateRolesTs(): string {
|
|
||||||
return `import type { RoleDefinition } from "@uncaged/workflow";
|
|
||||||
import * as z from "zod/v4";
|
|
||||||
|
|
||||||
export const HELLO_TEMPLATE_DESCRIPTION =
|
|
||||||
"Minimal starter template: one greeter role, then END.";
|
|
||||||
|
|
||||||
export type HelloTemplateMeta = {
|
|
||||||
greeter: {
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const greeterMetaSchema = z.object({
|
|
||||||
message: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
|
||||||
description: "Says hello — replace with your first role.",
|
|
||||||
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
|
|
||||||
extractPrompt: "Extract the assistant's greeting as message.",
|
|
||||||
schema: greeterMetaSchema,
|
|
||||||
extractRefs: null,
|
|
||||||
};
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function templateModeratorTs(): string {
|
|
||||||
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow";
|
|
||||||
|
|
||||||
import type { HelloTemplateMeta } from "./roles.js";
|
|
||||||
|
|
||||||
export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
|
|
||||||
ctx: ModeratorContext<HelloTemplateMeta>,
|
|
||||||
) => {
|
|
||||||
if (ctx.steps.length === 0) {
|
|
||||||
return "greeter";
|
|
||||||
}
|
|
||||||
return END;
|
|
||||||
};
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function templateIndexTs(): string {
|
|
||||||
return `import type { WorkflowDefinition } from "@uncaged/workflow";
|
|
||||||
|
|
||||||
import { helloTemplateModerator } from "./moderator.js";
|
|
||||||
import {
|
|
||||||
HELLO_TEMPLATE_DESCRIPTION,
|
|
||||||
type HelloTemplateMeta,
|
|
||||||
greeterRole,
|
|
||||||
} from "./roles.js";
|
|
||||||
|
|
||||||
export {
|
|
||||||
HELLO_TEMPLATE_DESCRIPTION,
|
|
||||||
type HelloTemplateMeta,
|
|
||||||
greeterRole,
|
|
||||||
} from "./roles.js";
|
|
||||||
export { helloTemplateModerator } from "./moderator.js";
|
|
||||||
|
|
||||||
export const helloTemplateWorkflowDefinition: WorkflowDefinition<HelloTemplateMeta> = {
|
|
||||||
description: HELLO_TEMPLATE_DESCRIPTION,
|
|
||||||
roles: {
|
|
||||||
greeter: greeterRole,
|
|
||||||
},
|
|
||||||
moderator: helloTemplateModerator,
|
|
||||||
};
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cmdInitTemplate(
|
|
||||||
startDir: string,
|
|
||||||
templateName: string,
|
|
||||||
): Promise<Result<CmdInitTemplateSuccess, string>> {
|
|
||||||
const validated = validateWorkspaceSegment(templateName);
|
|
||||||
if (!validated.ok) {
|
|
||||||
return validated;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootResult = await findWorkflowWorkspaceRoot(startDir);
|
|
||||||
if (!rootResult.ok) {
|
|
||||||
return rootResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceRoot = rootResult.value;
|
|
||||||
const templateDir = join(workspaceRoot, "templates", templateName);
|
|
||||||
if (await pathExists(templateDir)) {
|
|
||||||
return err(`template already exists: ${templateDir}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await mkdir(join(templateDir, "src"), { recursive: true });
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
writeFile(join(templateDir, "package.json"), templatePackageJson(templateName), "utf8"),
|
|
||||||
writeFile(join(templateDir, "tsconfig.json"), templateTsconfigJson(), "utf8"),
|
|
||||||
writeFile(join(templateDir, "src", "roles.ts"), templateRolesTs(), "utf8"),
|
|
||||||
writeFile(join(templateDir, "src", "moderator.ts"), templateModeratorTs(), "utf8"),
|
|
||||||
writeFile(join(templateDir, "src", "index.ts"), templateIndexTs(), "utf8"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return ok({ templatePath: templateDir });
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
import {
|
||||||
|
readWorkerCtl,
|
||||||
|
resolveRunningHashForThread,
|
||||||
|
sendWorkerTcpCommand,
|
||||||
|
} from "../../worker-spawn.js";
|
||||||
|
|
||||||
|
type ThreadControlAction = "kill" | "pause" | "resume";
|
||||||
|
|
||||||
|
async function cmdThreadControl(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: string,
|
||||||
|
action: ThreadControlAction,
|
||||||
|
): Promise<Result<void, string>> {
|
||||||
|
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
|
||||||
|
if (!hashResult.ok) {
|
||||||
|
return hashResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctlResult = await readWorkerCtl(storageRoot, hashResult.value);
|
||||||
|
if (!ctlResult.ok) {
|
||||||
|
return ctlResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await sendWorkerTcpCommand(
|
||||||
|
ctlResult.value.port,
|
||||||
|
{ type: action, threadId },
|
||||||
|
{ awaitResponseLine: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdKill(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: string,
|
||||||
|
): Promise<Result<void, string>> {
|
||||||
|
return cmdThreadControl(storageRoot, threadId, "kill");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdPause(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: string,
|
||||||
|
): Promise<Result<void, string>> {
|
||||||
|
return cmdThreadControl(storageRoot, threadId, "pause");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdResume(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: string,
|
||||||
|
): Promise<Result<void, string>> {
|
||||||
|
return cmdThreadControl(storageRoot, threadId, "resume");
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import type { CommandEntry, DispatchGroupFn } from "../../cli-command-types.js";
|
||||||
|
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||||
|
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
|
||||||
|
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
|
||||||
|
import { parseLiveArgv } from "../../live-argv.js";
|
||||||
|
import { parseRunArgv } from "../../run-argv.js";
|
||||||
|
import { cmdKill, cmdPause, cmdResume } from "./control.js";
|
||||||
|
import { cmdFork, parseForkArgv } from "./fork.js";
|
||||||
|
import { cmdThreads } from "./list.js";
|
||||||
|
import { cmdLive } from "./live.js";
|
||||||
|
import { cmdPs } from "./ps.js";
|
||||||
|
import { cmdThreadRemove } from "./rm.js";
|
||||||
|
import { cmdRun } from "./run.js";
|
||||||
|
import { cmdThreadShow } from "./show.js";
|
||||||
|
|
||||||
|
function usageText(): string {
|
||||||
|
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchRun(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const parsed = parseRunArgv(argv);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: ${parsed.error}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cmdRun(
|
||||||
|
storageRoot,
|
||||||
|
parsed.value.name,
|
||||||
|
parsed.value.prompt,
|
||||||
|
parsed.value.maxRounds,
|
||||||
|
);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
printCliLine(result.value.threadId);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchPs(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
if (argv.length > 0) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: ps takes no arguments`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
for (const line of await cmdPs(storageRoot)) {
|
||||||
|
printCliLine(line);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchKill(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const threadId = argv[0];
|
||||||
|
if (threadId === undefined || argv.length > 1) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: kill requires <thread-id>`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdKill(storageRoot, threadId);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printCliLine(`kill sent for thread ${threadId}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchLive(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const parsed = parseLiveArgv(argv);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: ${parsed.error}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return cmdLive(storageRoot, parsed.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchPause(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const threadId = argv[0];
|
||||||
|
if (threadId === undefined || argv.length > 1) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: pause requires <thread-id>`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdPause(storageRoot, threadId);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printCliLine(`pause sent for thread ${threadId}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchResume(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const threadId = argv[0];
|
||||||
|
if (threadId === undefined || argv.length > 1) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: resume requires <thread-id>`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdResume(storageRoot, threadId);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printCliLine(`resume sent for thread ${threadId}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchThreadList(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const result = await cmdThreads(storageRoot, argv);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
for (const line of result.value) {
|
||||||
|
printCliLine(line);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchThreadShow(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const id = argv[0];
|
||||||
|
if (id === undefined || argv.length > 1) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: thread show requires <id>`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdThreadShow(storageRoot, id);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printCliLine(result.value);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchThreadRm(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const id = argv[0];
|
||||||
|
if (id === undefined || argv.length > 1) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: thread rm requires <id>`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdThreadRemove(storageRoot, id);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printCliLine(`removed thread ${id}`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchFork(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const parsed = parseForkArgv(argv);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: ${parsed.error}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdFork(storageRoot, parsed.value.threadId, parsed.value.fromRole);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printCliLine(result.value.threadId);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const THREAD_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||||
|
run: {
|
||||||
|
handler: dispatchRun,
|
||||||
|
args: "<name> [--prompt <text>] [--max-rounds N]",
|
||||||
|
description: "Start a new thread executing a workflow",
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
handler: dispatchThreadList,
|
||||||
|
args: "[name]",
|
||||||
|
description: "List threads, optionally filtered by workflow name",
|
||||||
|
},
|
||||||
|
show: { handler: dispatchThreadShow, args: "<id>", description: "Show thread details and state" },
|
||||||
|
rm: { handler: dispatchThreadRm, args: "<id>", description: "Remove a thread" },
|
||||||
|
fork: {
|
||||||
|
handler: dispatchFork,
|
||||||
|
args: "<thread-id> [--from-role <role>]",
|
||||||
|
description: "Fork a thread, optionally from a specific role",
|
||||||
|
},
|
||||||
|
ps: { handler: dispatchPs, args: "", description: "List running threads" },
|
||||||
|
kill: { handler: dispatchKill, args: "<thread-id>", description: "Kill a running thread" },
|
||||||
|
live: {
|
||||||
|
handler: dispatchLive,
|
||||||
|
args: "<thread-id> | --latest [--debug] [--role <name>]",
|
||||||
|
description: "Attach to a thread and stream output live",
|
||||||
|
},
|
||||||
|
pause: { handler: dispatchPause, args: "<thread-id>", description: "Pause a running thread" },
|
||||||
|
resume: { handler: dispatchResume, args: "<thread-id>", description: "Resume a paused thread" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createThreadDispatcher(deps: { dispatchGroup: DispatchGroupFn }) {
|
||||||
|
const { dispatchGroup } = deps;
|
||||||
|
return async function dispatchThread(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const result = dispatchGroup("thread", THREAD_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||||
|
if (result !== null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const sub = argv[0];
|
||||||
|
printCliError(`${usageText()}\n\nerror: unknown thread subcommand: ${sub}`);
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
}
|
||||||
+3
-3
@@ -2,9 +2,9 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
import { buildForkPlan, err, generateUlid, ok, type Result } from "@uncaged/workflow";
|
import { buildForkPlan, err, generateUlid, ok, type Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
import { pathExists, readTextFileIfExists } from "../../fs-utils.js";
|
||||||
import { resolveThreadDataPath } from "./thread-scan.js";
|
import { resolveThreadDataPath } from "../../thread-scan.js";
|
||||||
import { ensureWorkerForHash, sendWorkerTcpCommand } from "./worker-spawn.js";
|
import { ensureWorkerForHash, sendWorkerTcpCommand } from "../../worker-spawn.js";
|
||||||
|
|
||||||
export function parseForkArgv(
|
export function parseForkArgv(
|
||||||
argv: string[],
|
argv: string[],
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export { cmdKill, cmdPause, cmdResume } from "./control.js";
|
||||||
|
export { cmdFork, parseForkArgv } from "./fork.js";
|
||||||
|
export { cmdThreads } from "./list.js";
|
||||||
|
export type { LiveRoleRow } from "./live.js";
|
||||||
|
export {
|
||||||
|
cmdLive,
|
||||||
|
formatLiveDebugLine,
|
||||||
|
formatLiveTimeLabel,
|
||||||
|
LIVE_CONTENT_MAX_LINES,
|
||||||
|
renderLiveRoleStepLines,
|
||||||
|
} from "./live.js";
|
||||||
|
export { cmdPs } from "./ps.js";
|
||||||
|
export { cmdThreadRemove } from "./rm.js";
|
||||||
|
export { cmdRun } from "./run.js";
|
||||||
|
export { cmdThreadShow } from "./show.js";
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { err, ok, type Result } from "@uncaged/workflow";
|
import { err, ok, type Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
import { listHistoricalThreads } from "./thread-scan.js";
|
import { listHistoricalThreads } from "../../thread-scan.js";
|
||||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||||
|
|
||||||
export async function cmdThreads(
|
export async function cmdThreads(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
import { watch } from "node:fs";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CasStore,
|
||||||
|
createCasStore,
|
||||||
|
getContentMerklePayload,
|
||||||
|
getGlobalCasDir,
|
||||||
|
tryParseRoleStepRecord,
|
||||||
|
tryParseWorkflowResultRecord,
|
||||||
|
type WorkflowCompletion,
|
||||||
|
} from "@uncaged/workflow";
|
||||||
|
|
||||||
|
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||||
|
import { pathExists } from "../../fs-utils.js";
|
||||||
|
import type { ParsedLiveArgv } from "../../live-argv.js";
|
||||||
|
import { findLatestThreadDataPath, resolveThreadDataPath } from "../../thread-scan.js";
|
||||||
|
|
||||||
|
export const LIVE_CONTENT_MAX_LINES = 10;
|
||||||
|
|
||||||
|
export type LiveRoleRow = {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
meta: Record<string, unknown>;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatLiveTimeLabel(timestampMs: number): string {
|
||||||
|
const d = new Date(timestampMs);
|
||||||
|
const hh = String(d.getHours()).padStart(2, "0");
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||||
|
const ss = String(d.getSeconds()).padStart(2, "0");
|
||||||
|
return `${hh}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseColor(): boolean {
|
||||||
|
return process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightLiveRole(name: string): string {
|
||||||
|
if (!shouldUseColor()) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return `\x1b[1m\x1b[36m${name}\x1b[0m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dimGreyLine(line: string): string {
|
||||||
|
if (!shouldUseColor()) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
return `\x1b[2m\x1b[90m${line}\x1b[0m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLiveDebugLine(timestampMs: number, tag: string, message: string): string {
|
||||||
|
const label = `[${formatLiveTimeLabel(timestampMs)}] [${tag}] ${message.replace(/\n/g, " ")}`;
|
||||||
|
return dimGreyLine(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLiveRoleStepLines(row: LiveRoleRow, roleDisplay: string): string[] {
|
||||||
|
const header = `[${formatLiveTimeLabel(row.timestamp)}] ▶ ${roleDisplay}`;
|
||||||
|
const lines: string[] = [header];
|
||||||
|
const parts = row.content.split("\n");
|
||||||
|
const shown = parts.slice(0, LIVE_CONTENT_MAX_LINES);
|
||||||
|
for (const ln of shown) {
|
||||||
|
lines.push(` ${ln}`);
|
||||||
|
}
|
||||||
|
const omitted = parts.length - shown.length;
|
||||||
|
if (omitted > 0) {
|
||||||
|
lines.push(` … (${omitted} more line${omitted === 1 ? "" : "s"})`);
|
||||||
|
}
|
||||||
|
lines.push(` meta: ${JSON.stringify(row.meta)}`);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSummary(result: WorkflowCompletion): void {
|
||||||
|
printCliLine(`completed: returnCode=${result.returnCode} — ${result.summary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LiveSessionState = {
|
||||||
|
sawStart: boolean;
|
||||||
|
completed: boolean;
|
||||||
|
carry: string;
|
||||||
|
contentOffset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InfoLiveState = {
|
||||||
|
carry: string;
|
||||||
|
contentOffset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function tryParseInfoRecord(obj: Record<string, unknown>): {
|
||||||
|
tag: string;
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
} | null {
|
||||||
|
const tag = obj.tag;
|
||||||
|
const content = obj.content;
|
||||||
|
const timestamp = obj.timestamp;
|
||||||
|
if (
|
||||||
|
typeof tag !== "string" ||
|
||||||
|
typeof content !== "string" ||
|
||||||
|
typeof timestamp !== "number" ||
|
||||||
|
!Number.isFinite(timestamp)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { tag, content, timestamp };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleJsonlLine(
|
||||||
|
rawLine: string,
|
||||||
|
state: LiveSessionState,
|
||||||
|
roleFilter: string | null,
|
||||||
|
cas: CasStore,
|
||||||
|
): Promise<{ parseError: string | null; workflowResult: WorkflowCompletion | null }> {
|
||||||
|
const trimmed = rawLine.trim();
|
||||||
|
if (trimmed === "") {
|
||||||
|
return { parseError: null, workflowResult: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
let rec: unknown;
|
||||||
|
try {
|
||||||
|
rec = JSON.parse(trimmed) as unknown;
|
||||||
|
} catch {
|
||||||
|
return { parseError: "invalid JSON in thread data file", workflowResult: null };
|
||||||
|
}
|
||||||
|
if (rec === null || typeof rec !== "object") {
|
||||||
|
return { parseError: "invalid record in thread data file", workflowResult: null };
|
||||||
|
}
|
||||||
|
const obj = rec as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (!state.sawStart) {
|
||||||
|
state.sawStart = true;
|
||||||
|
return { parseError: null, workflowResult: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const wf = tryParseWorkflowResultRecord(obj);
|
||||||
|
if (wf !== null) {
|
||||||
|
state.completed = true;
|
||||||
|
return { parseError: null, workflowResult: wf };
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleRow = tryParseRoleStepRecord(obj);
|
||||||
|
if (roleRow === null) {
|
||||||
|
return {
|
||||||
|
parseError: "unrecognized record in thread data (expected role step or result)",
|
||||||
|
workflowResult: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleFilter !== null && roleRow.role !== roleFilter) {
|
||||||
|
return { parseError: null, workflowResult: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await getContentMerklePayload(cas, roleRow.contentHash);
|
||||||
|
const content =
|
||||||
|
payload !== null ? payload : `(content not in CAS; contentHash=${roleRow.contentHash})`;
|
||||||
|
|
||||||
|
const row: LiveRoleRow = {
|
||||||
|
role: roleRow.role,
|
||||||
|
content,
|
||||||
|
meta: roleRow.meta,
|
||||||
|
timestamp: roleRow.timestamp,
|
||||||
|
};
|
||||||
|
for (const outLine of renderLiveRoleStepLines(row, highlightLiveRole(row.role))) {
|
||||||
|
printCliLine(outLine);
|
||||||
|
}
|
||||||
|
return { parseError: null, workflowResult: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pumpNewContent(
|
||||||
|
dataPath: string,
|
||||||
|
state: LiveSessionState,
|
||||||
|
roleFilter: string | null,
|
||||||
|
cas: CasStore,
|
||||||
|
): Promise<number | null> {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await readFile(dataPath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length < state.contentOffset) {
|
||||||
|
state.contentOffset = 0;
|
||||||
|
state.carry = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = text.slice(state.contentOffset);
|
||||||
|
state.contentOffset = text.length;
|
||||||
|
state.carry += chunk;
|
||||||
|
|
||||||
|
const parts = state.carry.split("\n");
|
||||||
|
state.carry = parts.pop() ?? "";
|
||||||
|
|
||||||
|
for (const line of parts) {
|
||||||
|
const { parseError, workflowResult } = await handleJsonlLine(line, state, roleFilter, cas);
|
||||||
|
if (parseError !== null) {
|
||||||
|
printCliError(parseError);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (workflowResult !== null) {
|
||||||
|
printSummary(workflowResult);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pumpNewInfoContent(infoPath: string, state: InfoLiveState): Promise<void> {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await readFile(infoPath, "utf8");
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length < state.contentOffset) {
|
||||||
|
state.contentOffset = 0;
|
||||||
|
state.carry = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = text.slice(state.contentOffset);
|
||||||
|
state.contentOffset = text.length;
|
||||||
|
state.carry += chunk;
|
||||||
|
|
||||||
|
const parts = state.carry.split("\n");
|
||||||
|
state.carry = parts.pop() ?? "";
|
||||||
|
|
||||||
|
for (const line of parts) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let rec: unknown;
|
||||||
|
try {
|
||||||
|
rec = JSON.parse(trimmed) as unknown;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rec === null || typeof rec !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parsed = tryParseInfoRecord(rec as Record<string, unknown>);
|
||||||
|
if (parsed === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
printCliLine(formatLiveDebugLine(parsed.timestamp, parsed.tag, parsed.content));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type WatchPumpTask = {
|
||||||
|
path: string;
|
||||||
|
pump: () => Promise<number | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function runWatchPumpStep(
|
||||||
|
settled: () => boolean,
|
||||||
|
pump: () => Promise<number | null>,
|
||||||
|
closeAll: () => void,
|
||||||
|
finish: (code: number) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
if (settled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const code = await pump();
|
||||||
|
if (code !== null) {
|
||||||
|
closeAll();
|
||||||
|
finish(code);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
closeAll();
|
||||||
|
throw e instanceof Error ? e : new Error(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchLivePaths(params: { tasks: WatchPumpTask[]; signal: AbortSignal }): Promise<number> {
|
||||||
|
const { tasks, signal } = params;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (code: number): void => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
resolve(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pumpChains = new Map<string, Promise<void>>();
|
||||||
|
for (const t of tasks) {
|
||||||
|
pumpChains.set(t.path, Promise.resolve());
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchers: ReturnType<typeof watch>[] = [];
|
||||||
|
|
||||||
|
const closeAll = (): void => {
|
||||||
|
for (const w of watchers) {
|
||||||
|
w.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function schedulePump(path: string, pump: () => Promise<number | null>): void {
|
||||||
|
const prev = pumpChains.get(path) ?? Promise.resolve();
|
||||||
|
const next = (async () => {
|
||||||
|
await prev;
|
||||||
|
await runWatchPumpStep(() => settled, pump, closeAll, finish);
|
||||||
|
})();
|
||||||
|
pumpChains.set(path, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { path, pump } of tasks) {
|
||||||
|
const watcher = watch(path, (eventType) => {
|
||||||
|
if (eventType === "rename") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schedulePump(path, pump);
|
||||||
|
});
|
||||||
|
watchers.push(watcher);
|
||||||
|
watcher.on("error", (err: Error) => {
|
||||||
|
closeAll();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAbort = (): void => {
|
||||||
|
closeAll();
|
||||||
|
finish(0);
|
||||||
|
};
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
|
||||||
|
for (const { path, pump } of tasks) {
|
||||||
|
schedulePump(path, pump);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type LiveThreadTarget = {
|
||||||
|
threadId: string;
|
||||||
|
dataPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function resolveLiveThreadTarget(
|
||||||
|
storageRoot: string,
|
||||||
|
parsed: ParsedLiveArgv,
|
||||||
|
): Promise<LiveThreadTarget | null> {
|
||||||
|
if (parsed.latest) {
|
||||||
|
const found = await findLatestThreadDataPath(storageRoot);
|
||||||
|
if (found === null) {
|
||||||
|
printCliError("live: no threads found");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = parsed.threadId;
|
||||||
|
if (id === null) {
|
||||||
|
printCliError("live: internal error: missing thread id");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const resolved = await resolveThreadDataPath(storageRoot, id);
|
||||||
|
if (resolved === null) {
|
||||||
|
printCliError(`thread not found: ${id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { threadId: id, dataPath: resolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildLiveWatchTasks(params: {
|
||||||
|
dataPath: string;
|
||||||
|
infoPath: string;
|
||||||
|
debug: boolean;
|
||||||
|
dataState: LiveSessionState;
|
||||||
|
infoState: InfoLiveState;
|
||||||
|
roleFilter: string | null;
|
||||||
|
cas: CasStore;
|
||||||
|
}): Promise<WatchPumpTask[]> {
|
||||||
|
const { dataPath, infoPath, debug, dataState, infoState, roleFilter, cas } = params;
|
||||||
|
const tasks: WatchPumpTask[] = [
|
||||||
|
{
|
||||||
|
path: dataPath,
|
||||||
|
pump: () => pumpNewContent(dataPath, dataState, roleFilter, cas),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (debug && (await pathExists(infoPath))) {
|
||||||
|
tasks.push({
|
||||||
|
path: infoPath,
|
||||||
|
pump: async () => {
|
||||||
|
await pumpNewInfoContent(infoPath, infoState);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cmdLive(storageRoot: string, parsed: ParsedLiveArgv): Promise<number> {
|
||||||
|
const target = await resolveLiveThreadTarget(storageRoot, parsed);
|
||||||
|
if (target === null) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { threadId, dataPath } = target;
|
||||||
|
const roleFilter = parsed.role;
|
||||||
|
const infoPath = join(dirname(dataPath), `${threadId}.info.jsonl`);
|
||||||
|
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||||
|
|
||||||
|
const dataState: LiveSessionState = {
|
||||||
|
sawStart: false,
|
||||||
|
completed: false,
|
||||||
|
carry: "",
|
||||||
|
contentOffset: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const infoState: InfoLiveState = {
|
||||||
|
carry: "",
|
||||||
|
contentOffset: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const onSigInt = (): void => {
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
process.on("SIGINT", onSigInt);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstData = await pumpNewContent(dataPath, dataState, roleFilter, cas);
|
||||||
|
if (firstData === 1) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.debug && (await pathExists(infoPath))) {
|
||||||
|
await pumpNewInfoContent(infoPath, infoState);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstData === 0 || dataState.completed) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = await buildLiveWatchTasks({
|
||||||
|
dataPath,
|
||||||
|
infoPath,
|
||||||
|
debug: parsed.debug,
|
||||||
|
dataState,
|
||||||
|
infoState,
|
||||||
|
roleFilter,
|
||||||
|
cas,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await watchLivePaths({ tasks, signal: controller.signal });
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
printCliError(`live: ${message}`);
|
||||||
|
return 1;
|
||||||
|
} finally {
|
||||||
|
process.off("SIGINT", onSigInt);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { listRunningThreads } from "./thread-scan.js";
|
import { listRunningThreads } from "../../thread-scan.js";
|
||||||
|
|
||||||
export async function cmdPs(storageRoot: string): Promise<string[]> {
|
export async function cmdPs(storageRoot: string): Promise<string[]> {
|
||||||
const rows = await listRunningThreads(storageRoot);
|
const rows = await listRunningThreads(storageRoot);
|
||||||
+1
-17
@@ -3,23 +3,7 @@ import { dirname, join } from "node:path";
|
|||||||
|
|
||||||
import { err, garbageCollectCas, ok, type Result } from "@uncaged/workflow";
|
import { err, garbageCollectCas, ok, type Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
import { readTextFileIfExists } from "./fs-utils.js";
|
import { resolveThreadDataPath } from "../../thread-scan.js";
|
||||||
import { resolveThreadDataPath } from "./thread-scan.js";
|
|
||||||
|
|
||||||
export async function cmdThreadShow(
|
|
||||||
storageRoot: string,
|
|
||||||
threadId: string,
|
|
||||||
): Promise<Result<string, string>> {
|
|
||||||
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
|
||||||
if (dataPath === null) {
|
|
||||||
return err(`thread not found: ${threadId}`);
|
|
||||||
}
|
|
||||||
const text = await readTextFileIfExists(dataPath);
|
|
||||||
if (text === null) {
|
|
||||||
return err(`thread data missing: ${threadId}`);
|
|
||||||
}
|
|
||||||
return ok(text.endsWith("\n") ? text.slice(0, -1) : text);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cmdThreadRemove(
|
export async function cmdThreadRemove(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
+2
-2
@@ -8,8 +8,8 @@ import {
|
|||||||
type Result,
|
type Result,
|
||||||
readWorkflowRegistry,
|
readWorkflowRegistry,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow";
|
||||||
import { ensureWorkerForHash, sendWorkerTcpCommand } from "./worker-spawn.js";
|
import { ensureWorkerForHash, sendWorkerTcpCommand } from "../../worker-spawn.js";
|
||||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||||
|
|
||||||
export async function cmdRun(
|
export async function cmdRun(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { err, ok, type Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
import { readTextFileIfExists } from "../../fs-utils.js";
|
||||||
|
import { resolveThreadDataPath } from "../../thread-scan.js";
|
||||||
|
|
||||||
|
export async function cmdThreadShow(
|
||||||
|
storageRoot: string,
|
||||||
|
threadId: string,
|
||||||
|
): Promise<Result<string, string>> {
|
||||||
|
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||||
|
if (dataPath === null) {
|
||||||
|
return err(`thread not found: ${threadId}`);
|
||||||
|
}
|
||||||
|
const text = await readTextFileIfExists(dataPath);
|
||||||
|
if (text === null) {
|
||||||
|
return err(`thread data missing: ${threadId}`);
|
||||||
|
}
|
||||||
|
return ok(text.endsWith("\n") ? text.slice(0, -1) : text);
|
||||||
|
}
|
||||||
+2
-2
@@ -14,8 +14,8 @@ import {
|
|||||||
writeWorkflowRegistry,
|
writeWorkflowRegistry,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow";
|
||||||
|
|
||||||
import { storeWorkflowBundleArtifacts } from "./bundle-store.js";
|
import { storeWorkflowBundleArtifacts } from "../../bundle-store.js";
|
||||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||||
|
|
||||||
export type ParsedAddArgv = {
|
export type ParsedAddArgv = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type { CommandEntry, DispatchGroupFn } from "../../cli-command-types.js";
|
||||||
|
import { printCliError, printCliLine, printCliWarn } from "../../cli-output.js";
|
||||||
|
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
|
||||||
|
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
|
||||||
|
import { cmdAdd, formatAddSuccess, parseAddArgv } from "./add.js";
|
||||||
|
import { cmdHistory } from "./history.js";
|
||||||
|
import { cmdList, formatListLines } from "./list.js";
|
||||||
|
import { cmdRemove } from "./rm.js";
|
||||||
|
import { cmdRollback } from "./rollback.js";
|
||||||
|
import { cmdShow, formatShowYaml } from "./show.js";
|
||||||
|
|
||||||
|
function usageText(): string {
|
||||||
|
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchAdd(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const parsed = parseAddArgv(argv);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: ${parsed.error}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdAdd(storageRoot, parsed.value);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
for (const w of result.value.warnings) {
|
||||||
|
printCliWarn(w);
|
||||||
|
}
|
||||||
|
printCliLine(formatAddSuccess(parsed.value.name, parsed.value.filePath, result.value.hash));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchList(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
if (argv.length > 0) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: list takes no arguments`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdList(storageRoot);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
for (const line of formatListLines(result.value)) {
|
||||||
|
printCliLine(line);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchShow(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const name = argv[0];
|
||||||
|
if (name === undefined || argv.length > 1) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: show requires <name>`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdShow(storageRoot, name);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printCliLine(formatShowYaml(name, result.value));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchRemove(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const name = argv[0];
|
||||||
|
if (name === undefined || argv.length > 1) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: remove requires <name>`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdRemove(storageRoot, name);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printCliLine(`removed workflow "${name}" from registry`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchHistory(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const name = argv[0];
|
||||||
|
if (name === undefined || argv.length > 1) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: history requires <name>`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const result = await cmdHistory(storageRoot, name);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
for (const line of result.value) {
|
||||||
|
printCliLine(line);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchRollback(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const name = argv[0];
|
||||||
|
if (name === undefined || argv.length > 2) {
|
||||||
|
printCliError(`${usageText()}\n\nerror: rollback requires <name> [hash]`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const hashArg = argv[1];
|
||||||
|
const result = await cmdRollback(storageRoot, name, hashArg === undefined ? null : hashArg);
|
||||||
|
if (!result.ok) {
|
||||||
|
printCliError(result.error);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
printCliLine(`rolled back workflow "${name}"`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WORKFLOW_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||||
|
add: {
|
||||||
|
handler: dispatchAdd,
|
||||||
|
args: "<name> <file.esm.js> [--types <path>]",
|
||||||
|
description: "Register a workflow bundle in the registry",
|
||||||
|
},
|
||||||
|
list: { handler: dispatchList, args: "", description: "List all registered workflows" },
|
||||||
|
show: {
|
||||||
|
handler: dispatchShow,
|
||||||
|
args: "<name>",
|
||||||
|
description: "Show details of a registered workflow",
|
||||||
|
},
|
||||||
|
rm: {
|
||||||
|
handler: dispatchRemove,
|
||||||
|
args: "<name>",
|
||||||
|
description: "Remove a workflow from the registry",
|
||||||
|
},
|
||||||
|
history: {
|
||||||
|
handler: dispatchHistory,
|
||||||
|
args: "<name>",
|
||||||
|
description: "Show version history of a workflow",
|
||||||
|
},
|
||||||
|
rollback: {
|
||||||
|
handler: dispatchRollback,
|
||||||
|
args: "<name> [hash]",
|
||||||
|
description: "Rollback a workflow to a previous version",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkflowDispatchDeps = {
|
||||||
|
dispatchGroup: DispatchGroupFn;
|
||||||
|
printDeprecation: (oldCmd: string, newCmd: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createWorkflowDispatcher(deps: WorkflowDispatchDeps) {
|
||||||
|
const { dispatchGroup, printDeprecation } = deps;
|
||||||
|
return async function dispatchWorkflow(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const result = dispatchGroup("workflow", WORKFLOW_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||||
|
if (result !== null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const sub = argv[0];
|
||||||
|
if (sub === "remove") {
|
||||||
|
printDeprecation("workflow remove", "workflow rm");
|
||||||
|
return dispatchRemove(storageRoot, argv.slice(1));
|
||||||
|
}
|
||||||
|
printCliError(`${usageText()}\n\nerror: unknown workflow subcommand: ${sub}`);
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-1
@@ -6,7 +6,7 @@ import {
|
|||||||
readWorkflowRegistry,
|
readWorkflowRegistry,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow";
|
||||||
|
|
||||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||||
|
|
||||||
export async function cmdHistory(
|
export async function cmdHistory(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type { CmdAddSuccess, ParsedAddArgv } from "./add.js";
|
||||||
|
export { cmdAdd, formatAddSuccess, parseAddArgv } from "./add.js";
|
||||||
|
export { cmdHistory } from "./history.js";
|
||||||
|
export { cmdList, formatListLines } from "./list.js";
|
||||||
|
export { cmdRemove } from "./rm.js";
|
||||||
|
export { cmdRollback } from "./rollback.js";
|
||||||
|
export { cmdShow, formatShowYaml } from "./show.js";
|
||||||
+1
-1
@@ -7,7 +7,7 @@ import {
|
|||||||
writeWorkflowRegistry,
|
writeWorkflowRegistry,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow";
|
||||||
|
|
||||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||||
|
|
||||||
export async function cmdRemove(storageRoot: string, name: string): Promise<Result<void, string>> {
|
export async function cmdRemove(storageRoot: string, name: string): Promise<Result<void, string>> {
|
||||||
const nameOk = validateCliWorkflowName(name);
|
const nameOk = validateCliWorkflowName(name);
|
||||||
+2
-2
@@ -10,8 +10,8 @@ import {
|
|||||||
writeWorkflowRegistry,
|
writeWorkflowRegistry,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow";
|
||||||
|
|
||||||
import { pathExists } from "./fs-utils.js";
|
import { pathExists } from "../../fs-utils.js";
|
||||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||||
|
|
||||||
export async function cmdRollback(
|
export async function cmdRollback(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow";
|
||||||
import { stringify } from "yaml";
|
import { stringify } from "yaml";
|
||||||
|
|
||||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||||
|
|
||||||
export async function cmdShow(
|
export async function cmdShow(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { err, ok, type Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
export type ParsedLiveArgv = {
|
||||||
|
threadId: string | null;
|
||||||
|
latest: boolean;
|
||||||
|
debug: boolean;
|
||||||
|
role: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LiveArgvScan = {
|
||||||
|
latest: boolean;
|
||||||
|
debug: boolean;
|
||||||
|
role: string | null;
|
||||||
|
threadId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function applyLiveArgvToken(argv: string[], i: number, s: LiveArgvScan): Result<number, string> {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a === "--latest") {
|
||||||
|
s.latest = true;
|
||||||
|
return ok(i + 1);
|
||||||
|
}
|
||||||
|
if (a === "--debug") {
|
||||||
|
s.debug = true;
|
||||||
|
return ok(i + 1);
|
||||||
|
}
|
||||||
|
if (a === "--role") {
|
||||||
|
const v = argv[i + 1];
|
||||||
|
if (v === undefined || v.startsWith("--")) {
|
||||||
|
return err("missing value for --role");
|
||||||
|
}
|
||||||
|
s.role = v;
|
||||||
|
return ok(i + 2);
|
||||||
|
}
|
||||||
|
if (a.startsWith("--")) {
|
||||||
|
return err(`unknown live flag: ${a}`);
|
||||||
|
}
|
||||||
|
if (s.threadId !== null) {
|
||||||
|
return err("unexpected extra argument");
|
||||||
|
}
|
||||||
|
s.threadId = a;
|
||||||
|
return ok(i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLiveArgv(argv: string[]): Result<ParsedLiveArgv, string> {
|
||||||
|
const s: LiveArgvScan = {
|
||||||
|
latest: false,
|
||||||
|
debug: false,
|
||||||
|
role: null,
|
||||||
|
threadId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < argv.length) {
|
||||||
|
const step = applyLiveArgvToken(argv, i, s);
|
||||||
|
if (!step.ok) {
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
i = step.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.latest && s.threadId !== null) {
|
||||||
|
return err("live --latest does not take <thread-id>");
|
||||||
|
}
|
||||||
|
if (!s.latest && s.threadId === null) {
|
||||||
|
return err("live requires <thread-id> or --latest");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
threadId: s.threadId,
|
||||||
|
latest: s.latest,
|
||||||
|
debug: s.debug,
|
||||||
|
role: s.role,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { getCommandRegistry } from "./cli-registry.js";
|
||||||
|
|
||||||
|
type SkillTopic = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
format: () => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SKILL_TOPICS: ReadonlyArray<SkillTopic> = [
|
||||||
|
{ name: "cli", description: "Full CLI command reference", format: formatSkillCli },
|
||||||
|
{
|
||||||
|
name: "develop",
|
||||||
|
description: "Guide for agents executing roles inside a workflow",
|
||||||
|
format: formatSkillDevelop,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "author",
|
||||||
|
description: "Guide for building and publishing workflow bundles",
|
||||||
|
format: formatSkillAuthor,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getSkillTopics(): ReadonlyArray<{ name: string; description: string }> {
|
||||||
|
return SKILL_TOPICS.map((t) => ({ name: t.name, description: t.description }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSkillTopic(topic: string): string | null {
|
||||||
|
const entry = SKILL_TOPICS.find((t) => t.name === topic);
|
||||||
|
if (entry === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.format();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSkillIndex(): string {
|
||||||
|
const rows = SKILL_TOPICS.map((t) => `| \`${t.name}\` | ${t.description} |`);
|
||||||
|
return `# uncaged-workflow skill
|
||||||
|
|
||||||
|
Available topics:
|
||||||
|
|
||||||
|
| Topic | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
${rows.join("\n")}
|
||||||
|
|
||||||
|
Usage: \`uncaged-workflow skill <topic>\`
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── cli topic (existing full reference) ────────────────────────────────
|
||||||
|
|
||||||
|
function formatSkillCli(): string {
|
||||||
|
const groups = getCommandRegistry();
|
||||||
|
|
||||||
|
const commandSections: string[] = [];
|
||||||
|
for (const group of groups) {
|
||||||
|
const rows = group.commands.map((cmd) => {
|
||||||
|
const args = cmd.args ? `\`${cmd.args}\`` : "(none)";
|
||||||
|
return `| \`${group.name} ${cmd.name}\` | ${args} | ${cmd.description} |`;
|
||||||
|
});
|
||||||
|
commandSections.push(
|
||||||
|
`### ${group.name}\n\n| Command | Args | Description |\n|---------|------|-------------|\n${rows.join("\n")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `# uncaged-workflow CLI Reference
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
| Concept | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Workflow** | A single-file ESM bundle (\`.esm.js\`) that exports \`run\` and \`descriptor\`. Identified by name and XXH64 hash. |
|
||||||
|
| **Bundle** | The physical \`.esm.js\` file stored in the bundles directory. Immutable once written. |
|
||||||
|
| **Thread** | A single execution of a workflow, identified by a ULID. Persists state as JSONL files. |
|
||||||
|
| **CAS** | Content-Addressable Storage. Per-thread key-value store keyed by content hash. |
|
||||||
|
| **Registry** | \`workflow.yaml\` — maps workflow names to their current and historical bundle hashes. |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
${commandSections.join("\n\n")}
|
||||||
|
|
||||||
|
### Top-level shortcuts
|
||||||
|
|
||||||
|
| Command | Equivalent | Description |
|
||||||
|
|---------|------------|-------------|
|
||||||
|
| \`run\` | \`thread run\` | Shortcut to start a thread |
|
||||||
|
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
|
||||||
|
|
||||||
|
## Typical Workflow
|
||||||
|
|
||||||
|
1. \`uncaged-workflow workflow add my-wf ./my-wf.esm.js\` — register a workflow
|
||||||
|
2. \`uncaged-workflow run my-wf --prompt "do the thing"\` — start a thread
|
||||||
|
3. \`uncaged-workflow live --latest\` — attach and watch output
|
||||||
|
4. \`uncaged-workflow thread show <thread-id>\` — inspect completed thread
|
||||||
|
|
||||||
|
## Exit Codes
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| 0 | Success |
|
||||||
|
| 1 | Error |
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| \`UNCAGED_WORKFLOW_STORAGE_ROOT\` | Override the default storage directory for all workflow data |
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── develop topic (for agents inside a workflow) ───────────────────────
|
||||||
|
|
||||||
|
function formatSkillDevelop(): string {
|
||||||
|
return `# Workflow Role Guide
|
||||||
|
|
||||||
|
Reference for agents executing roles (planner, coder, reviewer, etc.) inside a running workflow thread.
|
||||||
|
|
||||||
|
## Thread ID
|
||||||
|
|
||||||
|
Every thread has a 26-character Crockford Base32 ULID (e.g. \`06F03H5V6JTMDST6P3TVH42RWM\`).
|
||||||
|
|
||||||
|
It appears in the **first message** of the conversation. If unsure:
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
uncaged-workflow thread list
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## CAS (Content-Addressable Storage)
|
||||||
|
|
||||||
|
Store and retrieve content by hash, scoped to the current thread.
|
||||||
|
|
||||||
|
| Operation | Command |
|
||||||
|
|-----------|---------|
|
||||||
|
| **Store** | \`uncaged-workflow cas put <THREAD_ID> '<content>'\` → prints hash |
|
||||||
|
| **Read** | \`uncaged-workflow cas get <THREAD_ID> <HASH>\` → prints content |
|
||||||
|
| **List** | \`uncaged-workflow cas list <THREAD_ID>\` |
|
||||||
|
|
||||||
|
CAS is the **only** supported way to persist structured data (phase plans, review notes, etc.) within a thread. Do not use temp files.
|
||||||
|
|
||||||
|
## Meta Output
|
||||||
|
|
||||||
|
Each role must produce structured output that the moderator extracts. The exact schema depends on the role, but the pattern is:
|
||||||
|
|
||||||
|
1. Do your work (write code, run tests, etc.)
|
||||||
|
2. Output a compact JSON object matching the role's schema
|
||||||
|
3. The moderator extracts and validates it automatically
|
||||||
|
|
||||||
|
## Thread Context
|
||||||
|
|
||||||
|
The conversation history contains outputs from previous roles. Read it to understand:
|
||||||
|
- What task was requested (from the initial prompt)
|
||||||
|
- What previous roles produced (plans, code changes, review results)
|
||||||
|
- What the moderator decided (which phase to work on, whether to retry)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── author topic (for workflow developers) ─────────────────────────────
|
||||||
|
|
||||||
|
function formatSkillAuthor(): string {
|
||||||
|
return `# Workflow Authoring Guide
|
||||||
|
|
||||||
|
How to build, test, and publish workflow bundles for uncaged-workflow.
|
||||||
|
|
||||||
|
## Bundle Structure
|
||||||
|
|
||||||
|
A workflow bundle is a single ESM file (\`.esm.js\`) that exports:
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
// Required exports
|
||||||
|
export const descriptor: WorkflowDescriptor;
|
||||||
|
export const run: WorkflowRun;
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## WorkflowDescriptor
|
||||||
|
|
||||||
|
Defines the workflow's metadata and role sequence:
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
type WorkflowDescriptor = {
|
||||||
|
name: string; // verb-first kebab-case, e.g. "solve-issue"
|
||||||
|
description: string; // one-line summary
|
||||||
|
roles: string[]; // ordered role names, e.g. ["planner", "coder", "reviewer"]
|
||||||
|
};
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## WorkflowRun
|
||||||
|
|
||||||
|
The main function that creates and returns a moderator:
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
type WorkflowRun = (ctx: WorkflowContext) => Moderator;
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
The **Moderator** controls the flow — it decides which role runs next, handles retries, and determines when the workflow is complete.
|
||||||
|
|
||||||
|
## Role Definition
|
||||||
|
|
||||||
|
Each role has:
|
||||||
|
|
||||||
|
| Field | Type | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| \`description\` | string | What the role does |
|
||||||
|
| \`systemPrompt\` | string | System prompt for the agent |
|
||||||
|
| \`extractPrompt\` | string | Instruction for extracting structured meta |
|
||||||
|
| \`schema\` | ZodSchema | Validates the extracted meta |
|
||||||
|
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
|
||||||
|
| \`extractMode\` | "single" | Extraction mode |
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
# 1. Initialize a workspace
|
||||||
|
uncaged-workflow init workspace my-workflow
|
||||||
|
|
||||||
|
# 2. Write your template (roles + moderator + descriptor)
|
||||||
|
|
||||||
|
# 3. Build the ESM bundle
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# 4. Register locally
|
||||||
|
uncaged-workflow workflow add my-workflow ./dist/my-workflow.esm.js
|
||||||
|
|
||||||
|
# 5. Test
|
||||||
|
uncaged-workflow run my-workflow --prompt "test task"
|
||||||
|
uncaged-workflow live --latest
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions.
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Legacy compat ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** @deprecated Use formatSkillTopic("cli") instead */
|
||||||
|
export function formatSkillDoc(): string {
|
||||||
|
return formatSkillCli();
|
||||||
|
}
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow";
|
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow";
|
||||||
|
|
||||||
/** Resolve storage root, honoring `UNCAGED_WORKFLOW_STORAGE_ROOT` for tests/tools. */
|
/**
|
||||||
|
* Resolve storage root with env var override support.
|
||||||
|
*
|
||||||
|
* Priority (highest first):
|
||||||
|
* 1. `UNCAGED_WORKFLOW_STORAGE_ROOT` — internal/test override
|
||||||
|
* 2. `WORKFLOW_STORAGE_ROOT` — user-facing override
|
||||||
|
* 3. Default (`~/.uncaged/workflow`)
|
||||||
|
*/
|
||||||
export function resolveWorkflowStorageRoot(): string {
|
export function resolveWorkflowStorageRoot(): string {
|
||||||
const override = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||||
if (override !== undefined && override !== "") {
|
if (internal !== undefined && internal !== "") {
|
||||||
return override;
|
return internal;
|
||||||
|
}
|
||||||
|
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
|
||||||
|
if (userOverride !== undefined && userOverride !== "") {
|
||||||
|
return userOverride;
|
||||||
}
|
}
|
||||||
return getDefaultWorkflowStorageRoot();
|
return getDefaultWorkflowStorageRoot();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readdir } from "node:fs/promises";
|
import { readdir, stat } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
||||||
@@ -15,6 +15,28 @@ export type HistoricalThreadRow = {
|
|||||||
workflowName: string | null;
|
workflowName: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function readThreadStartTimestampMs(dataPath: string): Promise<number | null> {
|
||||||
|
const text = await readTextFileIfExists(dataPath);
|
||||||
|
if (text === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const firstLine = text.split("\n")[0];
|
||||||
|
if (firstLine === undefined || firstLine.trim() === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(firstLine) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (parsed === null || typeof parsed !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const ts = (parsed as Record<string, unknown>).timestamp;
|
||||||
|
return typeof ts === "number" && Number.isFinite(ts) ? ts : null;
|
||||||
|
}
|
||||||
|
|
||||||
async function readWorkflowNameFromDataJsonl(dataPath: string): Promise<string | null> {
|
async function readWorkflowNameFromDataJsonl(dataPath: string): Promise<string | null> {
|
||||||
const text = await readTextFileIfExists(dataPath);
|
const text = await readTextFileIfExists(dataPath);
|
||||||
if (text === null) {
|
if (text === null) {
|
||||||
@@ -124,6 +146,50 @@ export async function listHistoricalThreads(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the thread whose `.data.jsonl` is newest by start-record `timestamp`,
|
||||||
|
* falling back to file `mtime` when the timestamp is missing.
|
||||||
|
* Tie-breaker: larger `mtime` wins when start timestamps are equal.
|
||||||
|
*/
|
||||||
|
export async function findLatestThreadDataPath(
|
||||||
|
storageRoot: string,
|
||||||
|
): Promise<{ threadId: string; dataPath: string } | null> {
|
||||||
|
const threads = await listHistoricalThreads(storageRoot, null);
|
||||||
|
if (threads.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let best: {
|
||||||
|
threadId: string;
|
||||||
|
dataPath: string;
|
||||||
|
primary: number;
|
||||||
|
secondary: number;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
for (const t of threads) {
|
||||||
|
const dataPath = join(storageRoot, "logs", t.hash, `${t.threadId}.data.jsonl`);
|
||||||
|
let mtimeMs = 0;
|
||||||
|
try {
|
||||||
|
const st = await stat(dataPath);
|
||||||
|
mtimeMs = st.mtimeMs;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const startTs = await readThreadStartTimestampMs(dataPath);
|
||||||
|
const primary = startTs !== null ? startTs : mtimeMs;
|
||||||
|
const secondary = mtimeMs;
|
||||||
|
if (
|
||||||
|
best === null ||
|
||||||
|
primary > best.primary ||
|
||||||
|
(primary === best.primary && secondary > best.secondary)
|
||||||
|
) {
|
||||||
|
best = { threadId: t.threadId, dataPath, primary, secondary };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best === null ? null : { threadId: best.threadId, dataPath: best.dataPath };
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveThreadDataPath(
|
export async function resolveThreadDataPath(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
threadId: string,
|
threadId: string,
|
||||||
|
|||||||
@@ -237,6 +237,30 @@ export async function sendWorkerTcpCommand(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function readWorkerCtl(
|
||||||
|
storageRoot: string,
|
||||||
|
hash: string,
|
||||||
|
): Promise<Result<WorkerCtl, string>> {
|
||||||
|
const ctlPath = join(storageRoot, "workers", `${hash}.json`);
|
||||||
|
const ctlText = await readTextFileIfExists(ctlPath);
|
||||||
|
if (ctlText === null) {
|
||||||
|
return err(`worker control file missing for bundle hash ${hash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctl: WorkerCtl;
|
||||||
|
try {
|
||||||
|
ctl = JSON.parse(ctlText) as WorkerCtl;
|
||||||
|
} catch {
|
||||||
|
return err(`corrupt worker control file: ${ctlPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof ctl.port !== "number" || ctl.port <= 0) {
|
||||||
|
return err(`invalid worker control file: ${ctlPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(ctl);
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveRunningHashForThread(
|
export async function resolveRunningHashForThread(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
threadId: string,
|
threadId: string,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "echo 'TODO'",
|
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "echo 'TODO'",
|
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "echo 'TODO'",
|
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/workflow-role-coder",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"type": "module",
|
|
||||||
"main": "src/index.ts",
|
|
||||||
"types": "src/index.ts",
|
|
||||||
"scripts": {
|
|
||||||
"build": "echo 'TODO'",
|
|
||||||
"test": "echo no tests"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/workflow": "workspace:*",
|
|
||||||
"zod": "^4.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { type CoderMeta, coderMetaSchema, coderRole } from "./coder.js";
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/workflow-role-committer",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"type": "module",
|
|
||||||
"main": "src/index.ts",
|
|
||||||
"types": "src/index.ts",
|
|
||||||
"scripts": {
|
|
||||||
"build": "echo 'TODO'",
|
|
||||||
"test": "bun test"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/workflow": "workspace:*",
|
|
||||||
"zod": "^4.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { type CommitterMeta, committerMetaSchema, committerRole } from "./committer.js";
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rootDir": "src",
|
|
||||||
"outDir": "dist",
|
|
||||||
"composite": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"],
|
|
||||||
"references": [{ "path": "../workflow" }]
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/workflow-role-planner",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"type": "module",
|
|
||||||
"main": "src/index.ts",
|
|
||||||
"types": "src/index.ts",
|
|
||||||
"scripts": {
|
|
||||||
"build": "echo 'TODO'",
|
|
||||||
"test": "echo no tests"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/workflow": "workspace:*",
|
|
||||||
"zod": "^4.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export {
|
|
||||||
type PlannerMeta,
|
|
||||||
phaseSchema,
|
|
||||||
plannerMetaSchema,
|
|
||||||
plannerRole,
|
|
||||||
} from "./planner.js";
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rootDir": "src",
|
|
||||||
"outDir": "dist",
|
|
||||||
"composite": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"],
|
|
||||||
"references": [{ "path": "../workflow" }]
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/workflow-role-preparer",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"type": "module",
|
|
||||||
"main": "src/index.ts",
|
|
||||||
"types": "src/index.ts",
|
|
||||||
"scripts": {
|
|
||||||
"build": "echo 'TODO'",
|
|
||||||
"test": "echo no tests"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/workflow": "workspace:*",
|
|
||||||
"zod": "^4.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rootDir": "src",
|
|
||||||
"outDir": "dist"
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { type ReviewerMeta, reviewerMetaSchema, reviewerRole } from "./reviewer.js";
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
|
||||||
import * as z from "zod/v4";
|
|
||||||
|
|
||||||
export const reviewerMetaSchema = z.discriminatedUnion("status", [
|
|
||||||
z.object({
|
|
||||||
status: z.literal("approved"),
|
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
status: z.literal("rejected"),
|
|
||||||
issues: z.array(z.string()).describe("blocking issues that must be fixed"),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
export type ReviewerMeta = z.infer<typeof reviewerMetaSchema>;
|
|
||||||
|
|
||||||
const REVIEWER_SYSTEM = `You are a code reviewer. Review the current git diff. Give a clear approve or reject verdict.
|
|
||||||
Only reject for blocking issues. End with your verdict.`;
|
|
||||||
|
|
||||||
export const reviewerRole: RoleDefinition<ReviewerMeta> = {
|
|
||||||
description: "Runs git diff checks and sets approved when the change is ready.",
|
|
||||||
systemPrompt: REVIEWER_SYSTEM,
|
|
||||||
extractPrompt:
|
|
||||||
"Extract the review verdict: approved or rejected. If rejected, list the blocking issues.",
|
|
||||||
schema: reviewerMetaSchema,
|
|
||||||
extractRefs: null,
|
|
||||||
extractMode: "single",
|
|
||||||
};
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rootDir": "src",
|
|
||||||
"outDir": "dist",
|
|
||||||
"composite": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"],
|
|
||||||
"references": [{ "path": "../workflow" }]
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
import { committerMetaSchema, committerRole } from "../src/committer.js";
|
import { committerMetaSchema, committerRole } from "../src/roles/committer.js";
|
||||||
|
|
||||||
describe("committerRole", () => {
|
describe("committerRole", () => {
|
||||||
test("committed sample validates against schema", () => {
|
test("committed sample validates against schema", () => {
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
END,
|
||||||
|
type ModeratorContext,
|
||||||
|
type RoleStep,
|
||||||
|
START,
|
||||||
|
validateWorkflowDescriptor,
|
||||||
|
} from "@uncaged/workflow";
|
||||||
|
import { buildDevelopDescriptor } from "../src/descriptor.js";
|
||||||
|
import { developModerator } from "../src/index.js";
|
||||||
|
import type { CommitterMeta, PlannerMeta } from "../src/roles/index.js";
|
||||||
|
import type { DevelopMeta } from "../src/roles.js";
|
||||||
|
|
||||||
|
const DEFAULT_PHASES: PlannerMeta["phases"] = [
|
||||||
|
{
|
||||||
|
hash: "4KNMR2PX",
|
||||||
|
title: "Do the work",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function makeStart(maxRounds: number): ModeratorContext<DevelopMeta>["start"] {
|
||||||
|
return {
|
||||||
|
role: START,
|
||||||
|
content: "Implement the feature",
|
||||||
|
meta: { maxRounds },
|
||||||
|
timestamp: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx(
|
||||||
|
maxRounds: number,
|
||||||
|
steps: ModeratorContext<DevelopMeta>["steps"],
|
||||||
|
): ModeratorContext<DevelopMeta> {
|
||||||
|
return {
|
||||||
|
threadId: "01TEST000000000000000000TR",
|
||||||
|
depth: 0,
|
||||||
|
start: makeStart(maxRounds),
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function plannerStep(phases: PlannerMeta["phases"] = DEFAULT_PHASES): RoleStep<DevelopMeta> {
|
||||||
|
return {
|
||||||
|
role: "planner",
|
||||||
|
contentHash: "STUBHASHPLANNER001",
|
||||||
|
meta: { phases },
|
||||||
|
refs: phases.map((p) => p.hash),
|
||||||
|
timestamp: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function coderStep(completedPhase = "4KNMR2PX"): RoleStep<DevelopMeta> {
|
||||||
|
return {
|
||||||
|
role: "coder",
|
||||||
|
contentHash: "STUBHASHCODER00001",
|
||||||
|
meta: { completedPhase, filesChanged: ["a.ts"], summary: "implemented" },
|
||||||
|
refs: [completedPhase],
|
||||||
|
timestamp: 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewerStep(approved: boolean): RoleStep<DevelopMeta> {
|
||||||
|
return {
|
||||||
|
role: "reviewer",
|
||||||
|
contentHash: "STUBHASHREVIEWER01",
|
||||||
|
meta: approved
|
||||||
|
? { status: "approved" as const }
|
||||||
|
: { status: "rejected" as const, issues: ["needs fix"] },
|
||||||
|
refs: [],
|
||||||
|
timestamp: 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function testerStep(passed: boolean): RoleStep<DevelopMeta> {
|
||||||
|
return {
|
||||||
|
role: "tester",
|
||||||
|
contentHash: "STUBHASHTESTER01",
|
||||||
|
meta: passed
|
||||||
|
? { status: "passed" as const, details: "all checks passed" }
|
||||||
|
: { status: "failed" as const, details: "lint failed" },
|
||||||
|
refs: [],
|
||||||
|
timestamp: 4,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function committerStep(meta: CommitterMeta): RoleStep<DevelopMeta> {
|
||||||
|
return {
|
||||||
|
role: "committer",
|
||||||
|
contentHash: "STUBHASHCOMMITTER1",
|
||||||
|
meta,
|
||||||
|
refs: [],
|
||||||
|
timestamp: 5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("developModerator", () => {
|
||||||
|
test("routes initial → planner → coder → reviewer → tester → committer → END", () => {
|
||||||
|
expect(developModerator(makeCtx(20, []))).toBe("planner");
|
||||||
|
expect(developModerator(makeCtx(20, [plannerStep()]))).toBe("coder");
|
||||||
|
expect(developModerator(makeCtx(20, [plannerStep(), coderStep()]))).toBe("reviewer");
|
||||||
|
expect(developModerator(makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true)]))).toBe(
|
||||||
|
"tester",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
developModerator(
|
||||||
|
makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true), testerStep(true)]),
|
||||||
|
),
|
||||||
|
).toBe("committer");
|
||||||
|
expect(
|
||||||
|
developModerator(
|
||||||
|
makeCtx(20, [
|
||||||
|
plannerStep(),
|
||||||
|
coderStep(),
|
||||||
|
reviewerStep(true),
|
||||||
|
testerStep(true),
|
||||||
|
committerStep({ status: "committed", branch: "feat/x", commitSha: "abc1234" }),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
).toBe(END);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reviewer rejects → coder retry when budget allows", () => {
|
||||||
|
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||||
|
plannerStep(),
|
||||||
|
coderStep(),
|
||||||
|
reviewerStep(false),
|
||||||
|
];
|
||||||
|
expect(developModerator(makeCtx(20, steps))).toBe("coder");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reviewer rejects → END when max rounds exhausted", () => {
|
||||||
|
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||||
|
plannerStep(),
|
||||||
|
coderStep(),
|
||||||
|
reviewerStep(false),
|
||||||
|
];
|
||||||
|
expect(developModerator(makeCtx(4, steps))).toBe(END);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tester failed → coder retry when budget allows", () => {
|
||||||
|
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||||
|
plannerStep(),
|
||||||
|
coderStep(),
|
||||||
|
reviewerStep(true),
|
||||||
|
testerStep(false),
|
||||||
|
];
|
||||||
|
expect(developModerator(makeCtx(20, steps))).toBe("coder");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tester failed → END when max rounds exhausted", () => {
|
||||||
|
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||||
|
plannerStep(),
|
||||||
|
coderStep(),
|
||||||
|
reviewerStep(true),
|
||||||
|
testerStep(false),
|
||||||
|
];
|
||||||
|
expect(developModerator(makeCtx(5, steps))).toBe(END);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multiple planner phases → coder until all complete, then reviewer", () => {
|
||||||
|
const phases: PlannerMeta["phases"] = [
|
||||||
|
{ hash: "AA000001", title: "first phase" },
|
||||||
|
{ hash: "AA000002", title: "second phase" },
|
||||||
|
];
|
||||||
|
expect(developModerator(makeCtx(20, [plannerStep(phases)]))).toBe("coder");
|
||||||
|
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("AA000001")]))).toBe(
|
||||||
|
"coder",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
developModerator(
|
||||||
|
makeCtx(20, [plannerStep(phases), coderStep("AA000001"), coderStep("AA000002")]),
|
||||||
|
),
|
||||||
|
).toBe("reviewer");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one-shot coder reports only last phase hash → reviewer (moderator treats as all phases done)", () => {
|
||||||
|
const phases: PlannerMeta["phases"] = [
|
||||||
|
{ hash: "BB000001", title: "setup branch" },
|
||||||
|
{ hash: "BB000002", title: "write tests" },
|
||||||
|
{ hash: "BB000003", title: "verify" },
|
||||||
|
{ hash: "BB000004", title: "polish" },
|
||||||
|
];
|
||||||
|
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("BB000004")]))).toBe(
|
||||||
|
"reviewer",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unrecognised completedPhase hash → coder retry when budget allows", () => {
|
||||||
|
const phases: PlannerMeta["phases"] = [
|
||||||
|
{ hash: "CC000001", title: "first phase" },
|
||||||
|
{ hash: "CC000002", title: "second phase" },
|
||||||
|
];
|
||||||
|
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("all-done")]))).toBe(
|
||||||
|
"coder",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("incomplete phases → END when max rounds exhausted", () => {
|
||||||
|
const phases: PlannerMeta["phases"] = [
|
||||||
|
{ hash: "DD000001", title: "first phase" },
|
||||||
|
{ hash: "DD000002", title: "second phase" },
|
||||||
|
];
|
||||||
|
const steps: ModeratorContext<DevelopMeta>["steps"] = [
|
||||||
|
plannerStep(phases),
|
||||||
|
coderStep("DD000001"),
|
||||||
|
];
|
||||||
|
expect(developModerator(makeCtx(3, steps))).toBe(END);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("committer → END for any committer meta status", () => {
|
||||||
|
const committed = committerStep({ status: "committed", branch: "f", commitSha: "x" });
|
||||||
|
const recoverable = committerStep({
|
||||||
|
status: "recoverable",
|
||||||
|
error: "merge conflict",
|
||||||
|
logRef: null,
|
||||||
|
});
|
||||||
|
const unrecoverable = committerStep({
|
||||||
|
status: "unrecoverable",
|
||||||
|
error: "repo missing",
|
||||||
|
logRef: "log1",
|
||||||
|
});
|
||||||
|
const base: ModeratorContext<DevelopMeta>["steps"] = [
|
||||||
|
plannerStep(),
|
||||||
|
coderStep(),
|
||||||
|
reviewerStep(true),
|
||||||
|
testerStep(true),
|
||||||
|
];
|
||||||
|
expect(developModerator(makeCtx(20, [...base, committed]))).toBe(END);
|
||||||
|
expect(developModerator(makeCtx(20, [...base, recoverable]))).toBe(END);
|
||||||
|
expect(developModerator(makeCtx(20, [...base, unrecoverable]))).toBe(END);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildDevelopDescriptor", () => {
|
||||||
|
test("lists all roles with schemas that validate", () => {
|
||||||
|
const descriptor = buildDevelopDescriptor();
|
||||||
|
const validated = validateWorkflowDescriptor(descriptor);
|
||||||
|
expect(validated.ok).toBe(true);
|
||||||
|
if (!validated.ok) {
|
||||||
|
throw new Error(validated.error);
|
||||||
|
}
|
||||||
|
expect(Object.keys(validated.value.roles).sort()).toEqual([
|
||||||
|
"coder",
|
||||||
|
"committer",
|
||||||
|
"planner",
|
||||||
|
"reviewer",
|
||||||
|
"tester",
|
||||||
|
]);
|
||||||
|
for (const key of ["planner", "coder", "reviewer", "tester", "committer"] as const) {
|
||||||
|
const role = validated.value.roles[key];
|
||||||
|
expect(role).toBeDefined();
|
||||||
|
expect(typeof role.schema).toBe("object");
|
||||||
|
expect(role.schema).not.toBeNull();
|
||||||
|
expect(Array.isArray(role.schema)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
import { reviewerMetaSchema, reviewerRole } from "../src/reviewer.js";
|
import { reviewerMetaSchema, reviewerRole } from "../src/roles/reviewer.js";
|
||||||
|
|
||||||
describe("reviewerRole", () => {
|
describe("reviewerRole", () => {
|
||||||
test("approved sample validates against schema", () => {
|
test("approved sample validates against schema", () => {
|
||||||
+1
-2
@@ -1,11 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-role-reviewer",
|
"name": "@uncaged/workflow-template-develop",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "echo 'TODO'",
|
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { buildDescriptor } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
import { developModerator } from "./moderator.js";
|
||||||
|
import { DEVELOP_WORKFLOW_DESCRIPTION, developRoles } from "./roles.js";
|
||||||
|
|
||||||
|
export function buildDevelopDescriptor() {
|
||||||
|
return buildDescriptor({
|
||||||
|
description: DEVELOP_WORKFLOW_DESCRIPTION,
|
||||||
|
roles: developRoles,
|
||||||
|
moderator: developModerator,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
type AgentBinding,
|
||||||
|
createWorkflow,
|
||||||
|
type ExtractFn,
|
||||||
|
type LlmProvider,
|
||||||
|
type WorkflowDefinition,
|
||||||
|
type WorkflowFn,
|
||||||
|
} from "@uncaged/workflow";
|
||||||
|
|
||||||
|
import { developModerator } from "./moderator.js";
|
||||||
|
import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js";
|
||||||
|
|
||||||
|
export { buildDevelopDescriptor } from "./descriptor.js";
|
||||||
|
export { developModerator } from "./moderator.js";
|
||||||
|
export {
|
||||||
|
type CoderMeta,
|
||||||
|
type CommitterMeta,
|
||||||
|
coderMetaSchema,
|
||||||
|
coderRole,
|
||||||
|
committerMetaSchema,
|
||||||
|
committerRole,
|
||||||
|
type PlannerMeta,
|
||||||
|
phaseSchema,
|
||||||
|
plannerMetaSchema,
|
||||||
|
plannerRole,
|
||||||
|
type ReviewerMeta,
|
||||||
|
reviewerMetaSchema,
|
||||||
|
reviewerRole,
|
||||||
|
type TesterMeta,
|
||||||
|
testerMetaSchema,
|
||||||
|
testerRole,
|
||||||
|
} from "./roles/index.js";
|
||||||
|
export {
|
||||||
|
DEVELOP_WORKFLOW_DESCRIPTION,
|
||||||
|
type DevelopMeta,
|
||||||
|
type DevelopRoles,
|
||||||
|
developRoles,
|
||||||
|
} from "./roles.js";
|
||||||
|
|
||||||
|
export const developWorkflowDefinition: WorkflowDefinition<DevelopMeta> = {
|
||||||
|
description: DEVELOP_WORKFLOW_DESCRIPTION,
|
||||||
|
roles: developRoles,
|
||||||
|
moderator: developModerator,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createDevelopRun(
|
||||||
|
binding: AgentBinding,
|
||||||
|
extract: ExtractFn,
|
||||||
|
llmProvider: LlmProvider | null,
|
||||||
|
): WorkflowFn {
|
||||||
|
return createWorkflow(developWorkflowDefinition, binding, extract, llmProvider);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { Moderator, ModeratorContext } from "@uncaged/workflow";
|
||||||
|
import { END } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
import type { DevelopMeta } from "./roles.js";
|
||||||
|
|
||||||
|
function coderFinishedAllPlannedPhases(
|
||||||
|
phases: ReadonlyArray<{ hash: string }>,
|
||||||
|
coderCompletedPhases: ReadonlyArray<string>,
|
||||||
|
): boolean {
|
||||||
|
if (phases.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const plannedHashes = new Set(phases.map((p) => p.hash));
|
||||||
|
const lastHash = phases[phases.length - 1].hash;
|
||||||
|
const explicit = new Set(coderCompletedPhases.filter((h) => plannedHashes.has(h)));
|
||||||
|
if (phases.every((p) => explicit.has(p.hash))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (coderCompletedPhases.some((h) => h === lastHash)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextAfterCoder(
|
||||||
|
ctx: ModeratorContext<DevelopMeta>,
|
||||||
|
maxRounds: number,
|
||||||
|
): (keyof DevelopMeta & string) | typeof END {
|
||||||
|
const plannerStep = ctx.steps.find((s) => s.role === "planner");
|
||||||
|
if (plannerStep === undefined) {
|
||||||
|
return "reviewer";
|
||||||
|
}
|
||||||
|
const phases = plannerStep.meta.phases;
|
||||||
|
const coderCompletedPhases = ctx.steps
|
||||||
|
.filter((s) => s.role === "coder")
|
||||||
|
.map((s) => s.meta.completedPhase);
|
||||||
|
const allDone = coderFinishedAllPlannedPhases(phases, coderCompletedPhases);
|
||||||
|
if (allDone) {
|
||||||
|
return "reviewer";
|
||||||
|
}
|
||||||
|
if (ctx.steps.length < maxRounds - 1) {
|
||||||
|
return "coder";
|
||||||
|
}
|
||||||
|
return END;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const developModerator: Moderator<DevelopMeta> = (ctx) => {
|
||||||
|
const maxRounds = ctx.start.meta.maxRounds;
|
||||||
|
|
||||||
|
if (ctx.steps.length === 0) {
|
||||||
|
return "planner";
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = ctx.steps[ctx.steps.length - 1];
|
||||||
|
|
||||||
|
if (last.role === "planner") {
|
||||||
|
return "coder";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (last.role === "coder") {
|
||||||
|
return nextAfterCoder(ctx, maxRounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (last.role === "reviewer") {
|
||||||
|
if (last.meta.status === "approved") {
|
||||||
|
return "tester";
|
||||||
|
}
|
||||||
|
if (ctx.steps.length < maxRounds - 1) {
|
||||||
|
return "coder";
|
||||||
|
}
|
||||||
|
return END;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (last.role === "tester") {
|
||||||
|
if (last.meta.status === "passed") {
|
||||||
|
return "committer";
|
||||||
|
}
|
||||||
|
if (ctx.steps.length < maxRounds - 1) {
|
||||||
|
return "coder";
|
||||||
|
}
|
||||||
|
return END;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (last.role === "committer") {
|
||||||
|
return END;
|
||||||
|
}
|
||||||
|
|
||||||
|
return END;
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { RoleDefinition } from "@uncaged/workflow";
|
||||||
|
import { type CoderMeta, coderRole } from "./roles/coder.js";
|
||||||
|
import { type CommitterMeta, committerRole } from "./roles/committer.js";
|
||||||
|
import { type PlannerMeta, plannerRole } from "./roles/planner.js";
|
||||||
|
import { type ReviewerMeta, reviewerRole } from "./roles/reviewer.js";
|
||||||
|
import { type TesterMeta, testerRole } from "./roles/tester.js";
|
||||||
|
|
||||||
|
export const DEVELOP_WORKFLOW_DESCRIPTION =
|
||||||
|
"Plan phases, implement incrementally, review, verify with tests/build/lint, and commit (planner → coder [repeat per phase] → reviewer → tester → committer).";
|
||||||
|
|
||||||
|
export type DevelopMeta = {
|
||||||
|
planner: PlannerMeta;
|
||||||
|
coder: CoderMeta;
|
||||||
|
reviewer: ReviewerMeta;
|
||||||
|
tester: TesterMeta;
|
||||||
|
committer: CommitterMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DevelopRoles = {
|
||||||
|
[K in keyof DevelopMeta]: RoleDefinition<DevelopMeta[K]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const developRoles: DevelopRoles = {
|
||||||
|
planner: plannerRole,
|
||||||
|
coder: coderRole,
|
||||||
|
reviewer: reviewerRole,
|
||||||
|
tester: testerRole,
|
||||||
|
committer: committerRole,
|
||||||
|
};
|
||||||
+3
-11
@@ -11,21 +11,13 @@ export type CoderMeta = z.infer<typeof coderMetaSchema>;
|
|||||||
|
|
||||||
const CODER_SYSTEM = `You are a **coder**. Read the thread for the plan and work on the NEXT incomplete phase only.
|
const CODER_SYSTEM = `You are a **coder**. Read the thread for the plan and work on the NEXT incomplete phase only.
|
||||||
|
|
||||||
## Finding the current thread ID
|
Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and meta output guide.
|
||||||
|
|
||||||
The thread ID is a 26-character Crockford Base32 string (e.g. \`06F03H5V6JTMDST6P3TVH42RWM\`). It appears in the first message of this conversation. If you are unsure, run:
|
|
||||||
|
|
||||||
uncaged-workflow threads
|
|
||||||
|
|
||||||
and use the ID of the active thread.
|
|
||||||
|
|
||||||
## Reading phase details
|
## Reading phase details
|
||||||
|
|
||||||
Each planner phase is identified by a content-hash and a title. To read a phase's full details (name, description, acceptance criteria), run:
|
Each planner phase has a content-hash and title. Read full details with \`uncaged-workflow cas get <THREAD_ID> <HASH>\`.
|
||||||
|
|
||||||
uncaged-workflow cas get <THREAD_ID> <HASH>
|
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
|
||||||
|
|
||||||
Replace \`<THREAD_ID>\` with the actual thread ID and \`<HASH>\` with the phase hash from the plan.
|
|
||||||
|
|
||||||
## Completing a phase
|
## Completing a phase
|
||||||
|
|
||||||
+2
-2
@@ -21,12 +21,12 @@ export const committerMetaSchema = z.discriminatedUnion("status", [
|
|||||||
|
|
||||||
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
|
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
|
||||||
|
|
||||||
const COMMITTER_SYSTEM = `You are the git committer. Create a branch, commit the changes, and push.
|
const COMMITTER_SYSTEM = `You are the git committer. Create a branch and commit the changes.
|
||||||
Report the branch name and commit SHA. On failure, classify as recoverable or unrecoverable.
|
Report the branch name and commit SHA. On failure, classify as recoverable or unrecoverable.
|
||||||
Do not attempt to fix failures yourself.`;
|
Do not attempt to fix failures yourself.`;
|
||||||
|
|
||||||
export const committerRole: RoleDefinition<CommitterMeta> = {
|
export const committerRole: RoleDefinition<CommitterMeta> = {
|
||||||
description: "Creates branch, commits, and pushes when review passes.",
|
description: "Creates a branch and commits changes.",
|
||||||
systemPrompt: COMMITTER_SYSTEM,
|
systemPrompt: COMMITTER_SYSTEM,
|
||||||
extractPrompt:
|
extractPrompt:
|
||||||
"Extract the commit result: committed (with branch and SHA), recoverable failure, or unrecoverable failure. Include error details and log references if applicable.",
|
"Extract the commit result: committed (with branch and SHA), recoverable failure, or unrecoverable failure. Include error details and log references if applicable.",
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export { type CoderMeta, coderMetaSchema, coderRole } from "./coder.js";
|
||||||
|
export { type CommitterMeta, committerMetaSchema, committerRole } from "./committer.js";
|
||||||
|
export {
|
||||||
|
type PlannerMeta,
|
||||||
|
phaseSchema,
|
||||||
|
plannerMetaSchema,
|
||||||
|
plannerRole,
|
||||||
|
} from "./planner.js";
|
||||||
|
export { type ReviewerMeta, reviewerMetaSchema, reviewerRole } from "./reviewer.js";
|
||||||
|
export { type TesterMeta, testerMetaSchema, testerRole } from "./tester.js";
|
||||||
+11
-13
@@ -14,27 +14,25 @@ export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
|
|||||||
|
|
||||||
const PLANNER_SYSTEM = `You are a **planner** for a software task. Break the work into **sequential phases** the coder will execute one at a time.
|
const PLANNER_SYSTEM = `You are a **planner** for a software task. Break the work into **sequential phases** the coder will execute one at a time.
|
||||||
|
|
||||||
## Finding the current thread ID
|
Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and meta output guide.
|
||||||
|
|
||||||
The thread ID is a 26-character Crockford Base32 string (e.g. \`06F03H5V6JTMDST6P3TVH42RWM\`). It appears in the first message of this conversation. If you are unsure, run:
|
|
||||||
|
|
||||||
uncaged-workflow threads
|
|
||||||
|
|
||||||
and use the ID of the active thread.
|
|
||||||
|
|
||||||
## Storing phase details — MANDATORY
|
## Storing phase details — MANDATORY
|
||||||
|
|
||||||
For each phase you MUST store its full detail text in CAS using this exact CLI command:
|
For each phase, store its full detail text in CAS via \`uncaged-workflow cas put <THREAD_ID> '<content>'\`. The command prints a content-hash — use that as the phase identifier.
|
||||||
|
|
||||||
uncaged-workflow cas put <THREAD_ID> '# <name>
|
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
|
||||||
|
|
||||||
Description: <description>
|
**Do NOT store phase details in any other way** — the CLI is the only supported storage mechanism.
|
||||||
|
|
||||||
Acceptance: <acceptance>'
|
## Phase granularity
|
||||||
|
|
||||||
Replace \`<THREAD_ID>\` with the actual thread ID you found above. The command prints a content-hash to stdout — use that hash as the phase identifier.
|
Match the number of phases to task complexity:
|
||||||
|
- Trivial (add a config option, fix a typo, rename): 1 phase
|
||||||
|
- Small (a new feature touching 2-3 files): 1-2 phases
|
||||||
|
- Medium (cross-module refactor): 2-3 phases
|
||||||
|
- Large (new subsystem, architectural change): 3-5 phases
|
||||||
|
|
||||||
**Do NOT store phase details in any other way** (no temp files, no invented paths). The CLI command is the only supported storage mechanism.
|
Fewer phases is always better. Each phase must justify its existence — if two phases would be tested together anyway, merge them.
|
||||||
|
|
||||||
## Output format
|
## Output format
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { RoleDefinition } from "@uncaged/workflow";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
export const reviewerMetaSchema = z.discriminatedUnion("status", [
|
||||||
|
z.object({
|
||||||
|
status: z.literal("approved"),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
status: z.literal("rejected"),
|
||||||
|
issues: z.array(z.string()).describe("blocking issues that must be fixed"),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
export type ReviewerMeta = z.infer<typeof reviewerMetaSchema>;
|
||||||
|
|
||||||
|
const REVIEWER_SYSTEM = `You are a code reviewer. Review the git diff for correctness, consistency, and adherence to project conventions.
|
||||||
|
|
||||||
|
## Review process
|
||||||
|
|
||||||
|
1. Read the **preparer**'s output in the thread for project conventions (coding style, naming, commit format, etc.).
|
||||||
|
2. Review the diff against these conventions.
|
||||||
|
3. For documentation changes, verify that names, paths, and references match the actual codebase.
|
||||||
|
|
||||||
|
## Review checklist
|
||||||
|
|
||||||
|
- **Correctness** — does the code do what it claims? Logic bugs, off-by-one, missing returns?
|
||||||
|
- **Conventions** — naming, imports, code style per project rules?
|
||||||
|
- **Consistency** — do docs/comments match actual code? Are references current and accurate?
|
||||||
|
- **Edge cases** — missing error handling, null checks, boundary conditions?
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
- **Approve** only if there are zero issues
|
||||||
|
- **Reject** with specific issues that must be fixed — every issue you find is blocking
|
||||||
|
|
||||||
|
Be thorough. A false approve costs more than a false reject.`;
|
||||||
|
|
||||||
|
export const reviewerRole: RoleDefinition<ReviewerMeta> = {
|
||||||
|
description: "Runs git diff checks and sets approved when the change is ready.",
|
||||||
|
systemPrompt: REVIEWER_SYSTEM,
|
||||||
|
extractPrompt:
|
||||||
|
"Extract the review verdict: approved or rejected. If rejected, list the blocking issues.",
|
||||||
|
schema: reviewerMetaSchema,
|
||||||
|
extractRefs: null,
|
||||||
|
extractMode: "single",
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { RoleDefinition } from "@uncaged/workflow";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
export const testerMetaSchema = z.discriminatedUnion("status", [
|
||||||
|
z.object({
|
||||||
|
status: z.literal("passed"),
|
||||||
|
details: z.string(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
status: z.literal("failed"),
|
||||||
|
details: z.string(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type TesterMeta = z.infer<typeof testerMetaSchema>;
|
||||||
|
|
||||||
|
const TESTER_SYSTEM = `You are a tester. Run the project's test suite, build, and lint commands. Check what commands are available from the preparer's output in the thread. Report pass/fail with details of what failed.`;
|
||||||
|
|
||||||
|
export const testerRole: RoleDefinition<TesterMeta> = {
|
||||||
|
description: "Runs test, build, and lint commands and reports pass or fail with details.",
|
||||||
|
systemPrompt: TESTER_SYSTEM,
|
||||||
|
extractPrompt:
|
||||||
|
"Extract the verification result: passed with summary details, or failed with details of what broke.",
|
||||||
|
schema: testerMetaSchema,
|
||||||
|
extractRefs: null,
|
||||||
|
extractMode: "single",
|
||||||
|
};
|
||||||
@@ -11,42 +11,69 @@ import {
|
|||||||
START,
|
START,
|
||||||
validateWorkflowDescriptor,
|
validateWorkflowDescriptor,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow";
|
||||||
|
|
||||||
import type { CoderMeta } from "@uncaged/workflow-role-coder";
|
|
||||||
import type { PlannerMeta } from "@uncaged/workflow-role-planner";
|
|
||||||
import type { PreparerMeta } from "@uncaged/workflow-role-preparer";
|
|
||||||
|
|
||||||
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
||||||
|
import type { DeveloperMeta } from "../src/developer.js";
|
||||||
import { createSolveIssueRun, solveIssueModerator } from "../src/index.js";
|
import { createSolveIssueRun, solveIssueModerator } from "../src/index.js";
|
||||||
|
import type { PreparerMeta, SubmitterMeta } from "../src/roles/index.js";
|
||||||
import type { SolveIssueMeta } from "../src/roles.js";
|
import type { SolveIssueMeta } from "../src/roles.js";
|
||||||
|
|
||||||
const DEFAULT_PHASES: PlannerMeta["phases"] = [
|
function jsonResponse(payload: Record<string, unknown>): Response {
|
||||||
{
|
return new Response(JSON.stringify(payload), {
|
||||||
hash: "4KNMR2PX",
|
status: 200,
|
||||||
title: "Do the work",
|
headers: { "Content-Type": "application/json" },
|
||||||
},
|
});
|
||||||
];
|
}
|
||||||
|
|
||||||
const EXPECT_PLANNER_META: PlannerMeta = {
|
function readToolListFromBody(init: RequestInit | undefined): readonly Record<string, unknown>[] {
|
||||||
phases: [
|
if (init === undefined || init.body === undefined || init.body === null) {
|
||||||
{
|
return [];
|
||||||
hash: "7BQST3VW",
|
}
|
||||||
title: "placeholder phase",
|
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||||
},
|
const tools = body.tools;
|
||||||
],
|
if (!Array.isArray(tools)) {
|
||||||
};
|
return [];
|
||||||
|
}
|
||||||
|
return tools.filter((t): t is Record<string, unknown> => t !== null && typeof t === "object");
|
||||||
|
}
|
||||||
|
|
||||||
const EXPECT_CODER_META: CoderMeta = {
|
function singleToolName(tools: readonly Record<string, unknown>[]): string {
|
||||||
completedPhase: "7BQST3VW",
|
if (tools.length === 0) {
|
||||||
filesChanged: [],
|
return "extract";
|
||||||
summary: "",
|
}
|
||||||
};
|
const fn = tools[0].function as Record<string, unknown> | undefined;
|
||||||
|
return typeof fn?.name === "string" ? fn.name : "extract";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSingleModeResponse(args: Record<string, unknown>, toolName: string): Response {
|
||||||
|
return jsonResponse({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: { name: toolName, arguments: JSON.stringify(args) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReactModeResponse(args: Record<string, unknown>): Response {
|
||||||
|
// reactExtract accepts a plain-JSON assistant message and validates it
|
||||||
|
// directly against the schema, so we skip the cas_get / extract tool dance.
|
||||||
|
return jsonResponse({
|
||||||
|
choices: [{ message: { content: JSON.stringify(args) } }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unknown>>): () => void {
|
function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unknown>>): () => void {
|
||||||
const origFetch = globalThis.fetch;
|
const origFetch = globalThis.fetch;
|
||||||
let i = 0;
|
let i = 0;
|
||||||
const mockFetch = async (
|
const mockFetch = async (
|
||||||
input: Parameters<typeof fetch>[0],
|
_input: Parameters<typeof fetch>[0],
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
): Promise<Response> => {
|
): Promise<Response> => {
|
||||||
const args = sequence[i] ?? sequence[sequence.length - 1];
|
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||||
@@ -54,36 +81,11 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
|
|||||||
throw new Error("installMockChatCompletions: empty sequence");
|
throw new Error("installMockChatCompletions: empty sequence");
|
||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
void input;
|
const tools = readToolListFromBody(init);
|
||||||
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
|
if (tools.length > 1) {
|
||||||
const tools = body.tools;
|
return buildReactModeResponse(args);
|
||||||
const firstTool =
|
}
|
||||||
Array.isArray(tools) && tools.length > 0 && tools[0] !== null && typeof tools[0] === "object"
|
return buildSingleModeResponse(args, singleToolName(tools));
|
||||||
? (tools[0] as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
const fn =
|
|
||||||
firstTool !== null ? (firstTool.function as Record<string, unknown> | undefined) : undefined;
|
|
||||||
const toolName = typeof fn?.name === "string" ? fn.name : "extract";
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
message: {
|
|
||||||
tool_calls: [
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: toolName,
|
|
||||||
arguments: JSON.stringify(args),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
globalThis.fetch = Object.assign(mockFetch, {
|
globalThis.fetch = Object.assign(mockFetch, {
|
||||||
preconnect: origFetch.preconnect.bind(origFetch),
|
preconnect: origFetch.preconnect.bind(origFetch),
|
||||||
@@ -134,152 +136,86 @@ function preparerStep(): RoleStep<SolveIssueMeta> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function plannerStep(phases: PlannerMeta["phases"] = DEFAULT_PHASES): RoleStep<SolveIssueMeta> {
|
function developerStep(): RoleStep<SolveIssueMeta> {
|
||||||
return {
|
return {
|
||||||
role: "planner",
|
role: "developer",
|
||||||
contentHash: "STUBHASHPLANNER001",
|
contentHash: "STUBHASHDEVELOPER1",
|
||||||
meta: { phases },
|
meta: {
|
||||||
refs: phases.map((p) => p.hash),
|
branch: "feat/issue-1",
|
||||||
|
commitSha: "abc1234",
|
||||||
|
filesChanged: ["src/login.ts"],
|
||||||
|
summary: "Fixed flaky login test by stabilising async setup.",
|
||||||
|
},
|
||||||
|
refs: [],
|
||||||
timestamp: 1,
|
timestamp: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function coderStep(completedPhase = "4KNMR2PX"): RoleStep<SolveIssueMeta> {
|
function submitterStep(meta: SubmitterMeta): RoleStep<SolveIssueMeta> {
|
||||||
return {
|
return {
|
||||||
role: "coder",
|
role: "submitter",
|
||||||
contentHash: "STUBHASHCODER00001",
|
contentHash: "STUBHASHSUBMITTER1",
|
||||||
meta: { completedPhase, filesChanged: ["a.ts"], summary: "fixed" },
|
meta,
|
||||||
refs: [completedPhase],
|
refs: [],
|
||||||
timestamp: 2,
|
timestamp: 2,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function reviewerStep(approved: boolean): RoleStep<SolveIssueMeta> {
|
|
||||||
return {
|
|
||||||
role: "reviewer",
|
|
||||||
contentHash: "STUBHASHREVIEWER01",
|
|
||||||
meta: approved
|
|
||||||
? { status: "approved" as const }
|
|
||||||
: { status: "rejected" as const, issues: ["needs fix"] },
|
|
||||||
refs: [],
|
|
||||||
timestamp: 3,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function committerStep(): RoleStep<SolveIssueMeta> {
|
|
||||||
return {
|
|
||||||
role: "committer",
|
|
||||||
contentHash: "STUBHASHCOMMITTER1",
|
|
||||||
meta: { status: "committed", branch: "feat/issue-1", commitSha: "abc1234" },
|
|
||||||
refs: [],
|
|
||||||
timestamp: 4,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const stubExtract = createExtract({
|
const stubExtract = createExtract({
|
||||||
baseUrl: "http://127.0.0.1:9",
|
baseUrl: "http://127.0.0.1:9",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
model: "test",
|
model: "test",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const stubLlmProvider = {
|
||||||
|
baseUrl: "http://127.0.0.1:9",
|
||||||
|
apiKey: "",
|
||||||
|
model: "test",
|
||||||
|
};
|
||||||
|
|
||||||
describe("solveIssueModerator", () => {
|
describe("solveIssueModerator", () => {
|
||||||
test("routes preparer → planner → coder → reviewer → committer → END", () => {
|
test("routes initial → preparer → developer → submitter → END", () => {
|
||||||
expect(solveIssueModerator(makeCtx(20, []))).toBe("preparer");
|
expect(solveIssueModerator(makeCtx(20, []))).toBe("preparer");
|
||||||
expect(solveIssueModerator(makeCtx(20, [preparerStep()]))).toBe("planner");
|
expect(solveIssueModerator(makeCtx(20, [preparerStep()]))).toBe("developer");
|
||||||
expect(solveIssueModerator(makeCtx(20, [preparerStep(), plannerStep()]))).toBe("coder");
|
expect(solveIssueModerator(makeCtx(20, [preparerStep(), developerStep()]))).toBe("submitter");
|
||||||
expect(solveIssueModerator(makeCtx(20, [preparerStep(), plannerStep(), coderStep()]))).toBe(
|
|
||||||
"reviewer",
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
solveIssueModerator(
|
|
||||||
makeCtx(20, [preparerStep(), plannerStep(), coderStep(), reviewerStep(true)]),
|
|
||||||
),
|
|
||||||
).toBe("committer");
|
|
||||||
expect(
|
expect(
|
||||||
solveIssueModerator(
|
solveIssueModerator(
|
||||||
makeCtx(20, [
|
makeCtx(20, [
|
||||||
preparerStep(),
|
preparerStep(),
|
||||||
plannerStep(),
|
developerStep(),
|
||||||
coderStep(),
|
submitterStep({
|
||||||
reviewerStep(true),
|
status: "submitted",
|
||||||
committerStep(),
|
prUrl: "https://github.com/example/repo/pull/1",
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
).toBe(END);
|
).toBe(END);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reviewer rejects → coder retry when budget allows", () => {
|
test("submitter failed → END", () => {
|
||||||
const steps: ModeratorContext<SolveIssueMeta>["steps"] = [
|
|
||||||
plannerStep(),
|
|
||||||
coderStep(),
|
|
||||||
reviewerStep(false),
|
|
||||||
];
|
|
||||||
expect(solveIssueModerator(makeCtx(20, steps))).toBe("coder");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reviewer rejects → END when max rounds exhausted", () => {
|
|
||||||
const steps: ModeratorContext<SolveIssueMeta>["steps"] = [
|
|
||||||
plannerStep(),
|
|
||||||
coderStep(),
|
|
||||||
reviewerStep(false),
|
|
||||||
];
|
|
||||||
expect(solveIssueModerator(makeCtx(4, steps))).toBe(END);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("multiple planner phases → coder until all complete, then reviewer", () => {
|
|
||||||
const phases: PlannerMeta["phases"] = [
|
|
||||||
{
|
|
||||||
hash: "AA000001",
|
|
||||||
title: "first phase",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hash: "AA000002",
|
|
||||||
title: "second phase",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
expect(solveIssueModerator(makeCtx(20, [plannerStep(phases)]))).toBe("coder");
|
|
||||||
expect(solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("AA000001")]))).toBe(
|
|
||||||
"coder",
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
solveIssueModerator(
|
solveIssueModerator(
|
||||||
makeCtx(20, [plannerStep(phases), coderStep("AA000001"), coderStep("AA000002")]),
|
makeCtx(20, [
|
||||||
|
preparerStep(),
|
||||||
|
developerStep(),
|
||||||
|
submitterStep({ status: "failed", error: "gh not authenticated" }),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
).toBe("reviewer");
|
).toBe(END);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("one-shot coder reports only last phase hash → reviewer (moderator treats as all phases done)", () => {
|
test("returns END for any unexpected last step (defensive)", () => {
|
||||||
const phases: PlannerMeta["phases"] = [
|
// A submitter step with a pseudo-unknown future status would still be
|
||||||
{ hash: "BB000001", title: "setup branch" },
|
// routed to END, since the moderator is a closed switch over known roles.
|
||||||
{ hash: "BB000002", title: "write tests" },
|
expect(
|
||||||
{ hash: "BB000003", title: "verify" },
|
solveIssueModerator(
|
||||||
{ hash: "BB000004", title: "commit and pr" },
|
makeCtx(20, [
|
||||||
];
|
preparerStep(),
|
||||||
expect(solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("BB000004")]))).toBe(
|
developerStep(),
|
||||||
"reviewer",
|
submitterStep({ status: "submitted", prUrl: "https://example.com/pr/1" }),
|
||||||
);
|
]),
|
||||||
});
|
),
|
||||||
|
).toBe(END);
|
||||||
test("unrecognised completedPhase hash → coder retry when budget allows", () => {
|
|
||||||
const phases: PlannerMeta["phases"] = [
|
|
||||||
{ hash: "CC000001", title: "first phase" },
|
|
||||||
{ hash: "CC000002", title: "second phase" },
|
|
||||||
];
|
|
||||||
expect(solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("all-done")]))).toBe(
|
|
||||||
"coder",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("incomplete phases → END when max rounds exhausted", () => {
|
|
||||||
const phases: PlannerMeta["phases"] = [
|
|
||||||
{ hash: "DD000001", title: "first phase" },
|
|
||||||
{ hash: "DD000002", title: "second phase" },
|
|
||||||
];
|
|
||||||
const steps: ModeratorContext<SolveIssueMeta>["steps"] = [
|
|
||||||
plannerStep(phases),
|
|
||||||
coderStep("DD000001"),
|
|
||||||
];
|
|
||||||
expect(solveIssueModerator(makeCtx(3, steps))).toBe(END);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -296,7 +232,7 @@ describe("createSolveIssueRun", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("structured extraction yields preparer then planner meta from mocked chat completions", async () => {
|
test("structured extraction yields preparer meta from mocked chat completions", async () => {
|
||||||
const EXPECT_PREPARER_META: PreparerMeta = {
|
const EXPECT_PREPARER_META: PreparerMeta = {
|
||||||
repoPath: "/home/user/repos/test",
|
repoPath: "/home/user/repos/test",
|
||||||
defaultBranch: "main",
|
defaultBranch: "main",
|
||||||
@@ -308,12 +244,20 @@ describe("createSolveIssueRun", () => {
|
|||||||
buildCommand: "bun run build",
|
buildCommand: "bun run build",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
restoreFetch = installMockChatCompletions([EXPECT_PREPARER_META, EXPECT_PLANNER_META]);
|
restoreFetch = installMockChatCompletions([EXPECT_PREPARER_META]);
|
||||||
|
|
||||||
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
||||||
const cas = createCasStore(casDir);
|
const cas = createCasStore(casDir);
|
||||||
|
|
||||||
const run = createSolveIssueRun({ agent: async () => "" }, stubExtract, null);
|
// Override developer so the test does not spin up a child workflow.
|
||||||
|
const run = createSolveIssueRun(
|
||||||
|
{
|
||||||
|
agent: async () => "",
|
||||||
|
overrides: { developer: async () => "stub-root-hash" },
|
||||||
|
},
|
||||||
|
stubExtract,
|
||||||
|
stubLlmProvider,
|
||||||
|
);
|
||||||
const gen = run(
|
const gen = run(
|
||||||
{ prompt: "task", steps: [] },
|
{ prompt: "task", steps: [] },
|
||||||
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
|
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
|
||||||
@@ -325,14 +269,6 @@ describe("createSolveIssueRun", () => {
|
|||||||
}
|
}
|
||||||
expect(first.value.role).toBe("preparer");
|
expect(first.value.role).toBe("preparer");
|
||||||
expect(first.value.meta).toEqual(EXPECT_PREPARER_META);
|
expect(first.value.meta).toEqual(EXPECT_PREPARER_META);
|
||||||
|
|
||||||
const second = await gen.next();
|
|
||||||
expect(second.done).toBe(false);
|
|
||||||
if (second.done) {
|
|
||||||
throw new Error("expected yield");
|
|
||||||
}
|
|
||||||
expect(second.value.role).toBe("planner");
|
|
||||||
expect(second.value.meta).toEqual(EXPECT_PLANNER_META);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("per-role agent overrides default", async () => {
|
test("per-role agent overrides default", async () => {
|
||||||
@@ -342,11 +278,17 @@ describe("createSolveIssueRun", () => {
|
|||||||
conventions: null,
|
conventions: null,
|
||||||
toolchain: { packageManager: null, testCommand: null, lintCommand: null, buildCommand: null },
|
toolchain: { packageManager: null, testCommand: null, lintCommand: null, buildCommand: null },
|
||||||
};
|
};
|
||||||
restoreFetch = installMockChatCompletions([
|
const DEVELOPER_META: DeveloperMeta = {
|
||||||
PREPARER_META,
|
branch: "feat/x",
|
||||||
EXPECT_PLANNER_META,
|
commitSha: "abc1234",
|
||||||
EXPECT_CODER_META,
|
filesChanged: ["a.ts"],
|
||||||
]);
|
summary: "did the work",
|
||||||
|
};
|
||||||
|
const SUBMITTER_META: SubmitterMeta = {
|
||||||
|
status: "submitted",
|
||||||
|
prUrl: "https://github.com/example/repo/pull/2",
|
||||||
|
};
|
||||||
|
restoreFetch = installMockChatCompletions([PREPARER_META, DEVELOPER_META, SUBMITTER_META]);
|
||||||
|
|
||||||
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
||||||
const cas = createCasStore(casDir);
|
const cas = createCasStore(casDir);
|
||||||
@@ -363,18 +305,18 @@ describe("createSolveIssueRun", () => {
|
|||||||
calls.push("preparer");
|
calls.push("preparer");
|
||||||
return "";
|
return "";
|
||||||
},
|
},
|
||||||
planner: async () => {
|
developer: async () => {
|
||||||
calls.push("planner");
|
calls.push("developer");
|
||||||
return "";
|
return "stub-root-hash";
|
||||||
},
|
},
|
||||||
coder: async () => {
|
submitter: async () => {
|
||||||
calls.push("coder");
|
calls.push("submitter");
|
||||||
return "";
|
return "";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
stubExtract,
|
stubExtract,
|
||||||
null,
|
stubLlmProvider,
|
||||||
);
|
);
|
||||||
const gen = run(
|
const gen = run(
|
||||||
{ prompt: "task", steps: [] },
|
{ prompt: "task", steps: [] },
|
||||||
@@ -385,16 +327,65 @@ describe("createSolveIssueRun", () => {
|
|||||||
|
|
||||||
calls.length = 0;
|
calls.length = 0;
|
||||||
await gen.next();
|
await gen.next();
|
||||||
expect(calls).toEqual(["planner"]);
|
expect(calls).toEqual(["developer"]);
|
||||||
|
|
||||||
calls.length = 0;
|
calls.length = 0;
|
||||||
await gen.next();
|
await gen.next();
|
||||||
expect(calls).toEqual(["coder"]);
|
expect(calls).toEqual(["submitter"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("developer defaults to workflowAsAgent override (caller override still wins)", async () => {
|
||||||
|
const PREPARER_META: PreparerMeta = {
|
||||||
|
repoPath: "/tmp/r",
|
||||||
|
defaultBranch: "main",
|
||||||
|
conventions: null,
|
||||||
|
toolchain: { packageManager: null, testCommand: null, lintCommand: null, buildCommand: null },
|
||||||
|
};
|
||||||
|
const DEVELOPER_META: DeveloperMeta = {
|
||||||
|
branch: "feat/y",
|
||||||
|
commitSha: "def5678",
|
||||||
|
filesChanged: ["b.ts"],
|
||||||
|
summary: "more work",
|
||||||
|
};
|
||||||
|
restoreFetch = installMockChatCompletions([PREPARER_META, DEVELOPER_META]);
|
||||||
|
|
||||||
|
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
||||||
|
const cas = createCasStore(casDir);
|
||||||
|
|
||||||
|
let developerInvocations = 0;
|
||||||
|
const run = createSolveIssueRun(
|
||||||
|
{
|
||||||
|
agent: async () => "",
|
||||||
|
overrides: {
|
||||||
|
developer: async () => {
|
||||||
|
developerInvocations += 1;
|
||||||
|
return "stub-root-hash";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stubExtract,
|
||||||
|
stubLlmProvider,
|
||||||
|
);
|
||||||
|
const gen = run(
|
||||||
|
{ prompt: "task", steps: [] },
|
||||||
|
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
|
||||||
|
);
|
||||||
|
// preparer
|
||||||
|
await gen.next();
|
||||||
|
// developer (caller override should be invoked, NOT workflowAsAgent default)
|
||||||
|
const devYield = await gen.next();
|
||||||
|
expect(devYield.done).toBe(false);
|
||||||
|
if (devYield.done) {
|
||||||
|
throw new Error("expected yield");
|
||||||
|
}
|
||||||
|
expect(devYield.value.role).toBe("developer");
|
||||||
|
expect(devYield.value.meta).toEqual(DEVELOPER_META);
|
||||||
|
expect(developerInvocations).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildSolveIssueDescriptor", () => {
|
describe("buildSolveIssueDescriptor", () => {
|
||||||
test("lists all roles with schemas that validate", () => {
|
test("lists preparer, developer, submitter with schemas that validate", () => {
|
||||||
const descriptor = buildSolveIssueDescriptor();
|
const descriptor = buildSolveIssueDescriptor();
|
||||||
const validated = validateWorkflowDescriptor(descriptor);
|
const validated = validateWorkflowDescriptor(descriptor);
|
||||||
expect(validated.ok).toBe(true);
|
expect(validated.ok).toBe(true);
|
||||||
@@ -402,13 +393,11 @@ describe("buildSolveIssueDescriptor", () => {
|
|||||||
throw new Error(validated.error);
|
throw new Error(validated.error);
|
||||||
}
|
}
|
||||||
expect(Object.keys(validated.value.roles).sort()).toEqual([
|
expect(Object.keys(validated.value.roles).sort()).toEqual([
|
||||||
"coder",
|
"developer",
|
||||||
"committer",
|
|
||||||
"planner",
|
|
||||||
"preparer",
|
"preparer",
|
||||||
"reviewer",
|
"submitter",
|
||||||
]);
|
]);
|
||||||
for (const key of ["preparer", "planner", "coder", "reviewer", "committer"] as const) {
|
for (const key of ["preparer", "developer", "submitter"] as const) {
|
||||||
const role = validated.value.roles[key];
|
const role = validated.value.roles[key];
|
||||||
expect(role).toBeDefined();
|
expect(role).toBeDefined();
|
||||||
expect(typeof role.schema).toBe("object");
|
expect(typeof role.schema).toBe("object");
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { submitterMetaSchema, submitterRole } from "../src/roles/submitter.js";
|
||||||
|
|
||||||
|
describe("submitterRole", () => {
|
||||||
|
test("submitted sample validates against schema", () => {
|
||||||
|
const parsed = submitterMetaSchema.safeParse({
|
||||||
|
status: "submitted" as const,
|
||||||
|
prUrl: "https://github.com/example/repo/pull/42",
|
||||||
|
});
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed sample validates against schema", () => {
|
||||||
|
const parsed = submitterMetaSchema.safeParse({
|
||||||
|
status: "failed" as const,
|
||||||
|
error: "gh not authenticated",
|
||||||
|
});
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects unknown status discriminant", () => {
|
||||||
|
const parsed = submitterMetaSchema.safeParse({
|
||||||
|
status: "queued",
|
||||||
|
prUrl: "https://example.com",
|
||||||
|
});
|
||||||
|
expect(parsed.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exposes submitter system prompt", () => {
|
||||||
|
expect(submitterRole.systemPrompt).toContain("submitter");
|
||||||
|
expect(submitterRole.systemPrompt).toContain("pull request");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses single extract mode without refs", () => {
|
||||||
|
expect(submitterRole.extractMode).toBe("single");
|
||||||
|
expect(submitterRole.extractRefs).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,15 +5,10 @@
|
|||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "echo 'TODO'",
|
|
||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow": "workspace:*",
|
||||||
"@uncaged/workflow-role-committer": "workspace:*",
|
"zod": "^4.0.0"
|
||||||
"@uncaged/workflow-role-coder": "workspace:*",
|
|
||||||
"@uncaged/workflow-role-planner": "workspace:*",
|
|
||||||
"@uncaged/workflow-role-preparer": "workspace:*",
|
|
||||||
"@uncaged/workflow-role-reviewer": "workspace:*"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { RoleDefinition } from "@uncaged/workflow";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
export const developerMetaSchema = z.object({
|
||||||
|
branch: z.string(),
|
||||||
|
commitSha: z.string(),
|
||||||
|
filesChanged: z.array(z.string()),
|
||||||
|
summary: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DeveloperMeta = z.infer<typeof developerMetaSchema>;
|
||||||
|
|
||||||
|
const DEVELOPER_SYSTEM = `You are the **developer**. You delegate the implementation work to the \`develop\` workflow.
|
||||||
|
|
||||||
|
The actual implementation (planning → coding → reviewing → testing → committing) is handled by a child workflow that runs in your place. Your output is the Merkle DAG root hash of that child thread.
|
||||||
|
|
||||||
|
Pass through the task and let the child workflow do the work.`;
|
||||||
|
|
||||||
|
const DEVELOPER_EXTRACT_PROMPT = `The agent output is the root CAS hash of a child workflow thread. Use the cas_get tool to traverse the Merkle DAG and extract the developer summary.
|
||||||
|
|
||||||
|
Procedure:
|
||||||
|
1. cas_get(<rootHash>) — the root node lists all child step hashes (planner, coder, reviewer, tester, committer).
|
||||||
|
2. Find the committer step. cas_get its hash to read the committer's meta — extract branch and commitSha from there.
|
||||||
|
3. Find every coder step. cas_get each to read the coder's filesChanged. Union all filesChanged across coder steps.
|
||||||
|
4. Compose a short human-readable summary describing what the develop child workflow accomplished (drawn from the coder summaries, or a synthesis of them).
|
||||||
|
|
||||||
|
Return: { branch, commitSha, filesChanged, summary }.`;
|
||||||
|
|
||||||
|
export const developerRole: RoleDefinition<DeveloperMeta> = {
|
||||||
|
description:
|
||||||
|
"Delegates the actual implementation to the develop workflow (workflow-as-agent). Produces a summary by traversing the child thread's Merkle DAG.",
|
||||||
|
systemPrompt: DEVELOPER_SYSTEM,
|
||||||
|
extractPrompt: DEVELOPER_EXTRACT_PROMPT,
|
||||||
|
schema: developerMetaSchema,
|
||||||
|
extractRefs: () => [],
|
||||||
|
extractMode: "react",
|
||||||
|
};
|
||||||
@@ -5,39 +5,27 @@ import {
|
|||||||
type LlmProvider,
|
type LlmProvider,
|
||||||
type WorkflowDefinition,
|
type WorkflowDefinition,
|
||||||
type WorkflowFn,
|
type WorkflowFn,
|
||||||
|
workflowAsAgent,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow";
|
||||||
|
|
||||||
import { solveIssueModerator } from "./moderator.js";
|
import { solveIssueModerator } from "./moderator.js";
|
||||||
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
|
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
|
||||||
|
|
||||||
|
export { buildSolveIssueDescriptor } from "./descriptor.js";
|
||||||
export {
|
export {
|
||||||
type CoderMeta,
|
type DeveloperMeta,
|
||||||
coderMetaSchema,
|
developerMetaSchema,
|
||||||
coderRole,
|
developerRole,
|
||||||
} from "@uncaged/workflow-role-coder";
|
} from "./developer.js";
|
||||||
export {
|
export { solveIssueModerator } from "./moderator.js";
|
||||||
type CommitterMeta,
|
|
||||||
committerMetaSchema,
|
|
||||||
committerRole,
|
|
||||||
} from "@uncaged/workflow-role-committer";
|
|
||||||
export {
|
|
||||||
type PlannerMeta,
|
|
||||||
phaseSchema,
|
|
||||||
plannerMetaSchema,
|
|
||||||
plannerRole,
|
|
||||||
} from "@uncaged/workflow-role-planner";
|
|
||||||
export {
|
export {
|
||||||
type PreparerMeta,
|
type PreparerMeta,
|
||||||
preparerMetaSchema,
|
preparerMetaSchema,
|
||||||
preparerRole,
|
preparerRole,
|
||||||
} from "@uncaged/workflow-role-preparer";
|
type SubmitterMeta,
|
||||||
export {
|
submitterMetaSchema,
|
||||||
type ReviewerMeta,
|
submitterRole,
|
||||||
reviewerMetaSchema,
|
} from "./roles/index.js";
|
||||||
reviewerRole,
|
|
||||||
} from "@uncaged/workflow-role-reviewer";
|
|
||||||
export { buildSolveIssueDescriptor } from "./descriptor.js";
|
|
||||||
export { solveIssueModerator } from "./moderator.js";
|
|
||||||
export {
|
export {
|
||||||
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||||
type SolveIssueMeta,
|
type SolveIssueMeta,
|
||||||
@@ -51,10 +39,25 @@ export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> =
|
|||||||
moderator: solveIssueModerator,
|
moderator: solveIssueModerator,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the solve-issue {@link WorkflowFn}.
|
||||||
|
*
|
||||||
|
* The `developer` role always delegates to the registered `develop` workflow via
|
||||||
|
* {@link workflowAsAgent}; if the caller supplies their own `developer` override in
|
||||||
|
* `binding.overrides`, it takes precedence so tests and custom hosts can stub it.
|
||||||
|
*/
|
||||||
export function createSolveIssueRun(
|
export function createSolveIssueRun(
|
||||||
binding: AgentBinding,
|
binding: AgentBinding,
|
||||||
extract: ExtractFn,
|
extract: ExtractFn,
|
||||||
llmProvider: LlmProvider | null,
|
llmProvider: LlmProvider | null,
|
||||||
): WorkflowFn {
|
): WorkflowFn {
|
||||||
return createWorkflow(solveIssueWorkflowDefinition, binding, extract, llmProvider);
|
const developerOverride = binding.overrides?.developer ?? workflowAsAgent("develop");
|
||||||
|
const mergedBinding: AgentBinding = {
|
||||||
|
agent: binding.agent,
|
||||||
|
overrides: {
|
||||||
|
...(binding.overrides ?? {}),
|
||||||
|
developer: developerOverride,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return createWorkflow(solveIssueWorkflowDefinition, mergedBinding, extract, llmProvider);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,9 @@
|
|||||||
import type { Moderator, ModeratorContext } from "@uncaged/workflow";
|
import type { Moderator } from "@uncaged/workflow";
|
||||||
import { END } from "@uncaged/workflow";
|
import { END } from "@uncaged/workflow";
|
||||||
|
|
||||||
import type { SolveIssueMeta } from "./roles.js";
|
import type { SolveIssueMeta } from "./roles.js";
|
||||||
|
|
||||||
function coderFinishedAllPlannedPhases(
|
|
||||||
phases: ReadonlyArray<{ hash: string }>,
|
|
||||||
coderCompletedPhases: ReadonlyArray<string>,
|
|
||||||
): boolean {
|
|
||||||
if (phases.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const plannedHashes = new Set(phases.map((p) => p.hash));
|
|
||||||
const lastHash = phases[phases.length - 1].hash;
|
|
||||||
const explicit = new Set(coderCompletedPhases.filter((h) => plannedHashes.has(h)));
|
|
||||||
if (phases.every((p) => explicit.has(p.hash))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (coderCompletedPhases.some((h) => h === lastHash)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextAfterCoder(
|
|
||||||
ctx: ModeratorContext<SolveIssueMeta>,
|
|
||||||
maxRounds: number,
|
|
||||||
): (keyof SolveIssueMeta & string) | typeof END {
|
|
||||||
const plannerStep = ctx.steps.find((s) => s.role === "planner");
|
|
||||||
if (plannerStep === undefined) {
|
|
||||||
return "reviewer";
|
|
||||||
}
|
|
||||||
const phases = plannerStep.meta.phases;
|
|
||||||
const coderCompletedPhases = ctx.steps
|
|
||||||
.filter((s) => s.role === "coder")
|
|
||||||
.map((s) => s.meta.completedPhase);
|
|
||||||
const allDone = coderFinishedAllPlannedPhases(phases, coderCompletedPhases);
|
|
||||||
if (allDone) {
|
|
||||||
return "reviewer";
|
|
||||||
}
|
|
||||||
if (ctx.steps.length < maxRounds - 1) {
|
|
||||||
return "coder";
|
|
||||||
}
|
|
||||||
return END;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const solveIssueModerator: Moderator<SolveIssueMeta> = (ctx) => {
|
export const solveIssueModerator: Moderator<SolveIssueMeta> = (ctx) => {
|
||||||
const maxRounds = ctx.start.meta.maxRounds;
|
|
||||||
|
|
||||||
if (ctx.steps.length === 0) {
|
if (ctx.steps.length === 0) {
|
||||||
return "preparer";
|
return "preparer";
|
||||||
}
|
}
|
||||||
@@ -54,31 +11,14 @@ export const solveIssueModerator: Moderator<SolveIssueMeta> = (ctx) => {
|
|||||||
const last = ctx.steps[ctx.steps.length - 1];
|
const last = ctx.steps[ctx.steps.length - 1];
|
||||||
|
|
||||||
if (last.role === "preparer") {
|
if (last.role === "preparer") {
|
||||||
return "planner";
|
return "developer";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (last.role === "planner") {
|
if (last.role === "developer") {
|
||||||
return "coder";
|
return "submitter";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (last.role === "coder") {
|
if (last.role === "submitter") {
|
||||||
return nextAfterCoder(ctx, maxRounds);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (last.role === "reviewer") {
|
|
||||||
if (last.meta.status === "approved") {
|
|
||||||
return "committer";
|
|
||||||
}
|
|
||||||
if (ctx.steps.length < maxRounds - 1) {
|
|
||||||
return "coder";
|
|
||||||
}
|
|
||||||
return END;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (last.role === "committer") {
|
|
||||||
if (last.meta.status === "recoverable" && ctx.steps.length < maxRounds - 1) {
|
|
||||||
return "coder";
|
|
||||||
}
|
|
||||||
return END;
|
return END;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow";
|
||||||
import { type CoderMeta, coderRole } from "@uncaged/workflow-role-coder";
|
import { type DeveloperMeta, developerRole } from "./developer.js";
|
||||||
import { type CommitterMeta, committerRole } from "@uncaged/workflow-role-committer";
|
import { type PreparerMeta, preparerRole } from "./roles/preparer.js";
|
||||||
import { type PlannerMeta, plannerRole } from "@uncaged/workflow-role-planner";
|
import { type SubmitterMeta, submitterRole } from "./roles/submitter.js";
|
||||||
import { type PreparerMeta, preparerRole } from "@uncaged/workflow-role-preparer";
|
|
||||||
import { type ReviewerMeta, reviewerRole } from "@uncaged/workflow-role-reviewer";
|
|
||||||
|
|
||||||
export const SOLVE_ISSUE_WORKFLOW_DESCRIPTION =
|
export const SOLVE_ISSUE_WORKFLOW_DESCRIPTION =
|
||||||
"Prepare repo context, plan phases, implement incrementally, review, and commit to resolve an issue end-to-end (preparer → planner → coder [repeat per phase] → reviewer → committer).";
|
"Resolve an issue end-to-end by preparing the repo, delegating implementation to the develop workflow, and opening a pull request (preparer → developer → submitter).";
|
||||||
|
|
||||||
export type SolveIssueMeta = {
|
export type SolveIssueMeta = {
|
||||||
preparer: PreparerMeta;
|
preparer: PreparerMeta;
|
||||||
planner: PlannerMeta;
|
developer: DeveloperMeta;
|
||||||
coder: CoderMeta;
|
submitter: SubmitterMeta;
|
||||||
reviewer: ReviewerMeta;
|
|
||||||
committer: CommitterMeta;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SolveIssueRoles = {
|
export type SolveIssueRoles = {
|
||||||
@@ -22,8 +18,6 @@ export type SolveIssueRoles = {
|
|||||||
|
|
||||||
export const solveIssueRoles: SolveIssueRoles = {
|
export const solveIssueRoles: SolveIssueRoles = {
|
||||||
preparer: preparerRole,
|
preparer: preparerRole,
|
||||||
planner: plannerRole,
|
developer: developerRole,
|
||||||
coder: coderRole,
|
submitter: submitterRole,
|
||||||
reviewer: reviewerRole,
|
|
||||||
committer: committerRole,
|
|
||||||
};
|
};
|
||||||
|
|||||||
+1
@@ -3,3 +3,4 @@ export {
|
|||||||
preparerMetaSchema,
|
preparerMetaSchema,
|
||||||
preparerRole,
|
preparerRole,
|
||||||
} from "./preparer.js";
|
} from "./preparer.js";
|
||||||
|
export { type SubmitterMeta, submitterMetaSchema, submitterRole } from "./submitter.js";
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user