Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9407a26806 |
@@ -1,2 +0,0 @@
|
||||
[test]
|
||||
pathIgnorePatterns = ["dist/**"]
|
||||
@@ -1,259 +0,0 @@
|
||||
# @uncaged/workflow — Architecture
|
||||
|
||||
**Last updated:** 2026-05-06 by 小橘 🍊(NEKO Team)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32). No daemon — processes start on demand and exit when done.
|
||||
|
||||
## Package Structure
|
||||
|
||||
| Package | npm Name | Purpose |
|
||||
|---------|----------|---------|
|
||||
| `workflow` | `@uncaged/workflow` | Core: types, engine, ExtractFn, hash/ULID/registry |
|
||||
| `cli-workflow` | `@uncaged/cli-workflow` | CLI: `uncaged-workflow` command |
|
||||
| `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-llm` | `@uncaged/workflow-agent-llm` | OpenAI-compatible LLM agent |
|
||||
| `workflow-role-planner` | `@uncaged/workflow-role-planner` | Pure data: phased planning prompt + schema |
|
||||
| `workflow-role-coder` | `@uncaged/workflow-role-coder` | Pure data: coding prompt + schema |
|
||||
| `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 |
|
||||
|
||||
Monorepo with **bun workspace**, `workspace:*` protocol.
|
||||
|
||||
## Core Types
|
||||
|
||||
```typescript
|
||||
// --- Sentinel values ---
|
||||
const START = "__start__";
|
||||
const END = "__end__";
|
||||
|
||||
// --- RoleMeta: maps role names → their meta types ---
|
||||
type RoleMeta = Record<string, Record<string, unknown>>;
|
||||
|
||||
// --- Role Definition: pure data, no execution logic ---
|
||||
type RoleDefinition<Meta> = {
|
||||
description: string; // human-readable
|
||||
systemPrompt: string; // given to agent
|
||||
extractPrompt: string; // given to extractor
|
||||
schema: z.ZodType<Meta>; // meta shape (Zod v4)
|
||||
};
|
||||
|
||||
// --- Workflow Definition: pure data, no agent binding ---
|
||||
type WorkflowDefinition<M extends RoleMeta> = {
|
||||
description: string;
|
||||
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
|
||||
moderator: Moderator<M>;
|
||||
};
|
||||
|
||||
// --- Agent: raw string output, reads role info from context ---
|
||||
type AgentFn = (ctx: AgentContext) => Promise<string>;
|
||||
|
||||
// --- Agent Binding: runtime assignment ---
|
||||
type AgentBinding = {
|
||||
agent: AgentFn;
|
||||
overrides?: Partial<Record<string, AgentFn>>;
|
||||
};
|
||||
|
||||
// --- Extract: structured data from context ---
|
||||
type ExtractFn = <T>(schema: z.ZodType<T>, prompt: string, ctx: ExtractContext) => Promise<T>;
|
||||
|
||||
// --- Moderator: pure routing function ---
|
||||
type Moderator<M extends RoleMeta> = (ctx: ModeratorContext<M>) => (keyof M & string) | typeof END;
|
||||
|
||||
// --- Composition ---
|
||||
// createWorkflow(def, binding, extract) => WorkflowFn
|
||||
```
|
||||
|
||||
## Three-Phase Engine Loop
|
||||
|
||||
Each role execution has three distinct phases with progressive context:
|
||||
|
||||
```
|
||||
┌─→ Phase 1: MODERATOR
|
||||
│ Context: ModeratorContext { threadId, start, steps }
|
||||
│ Action: moderator(ctx) → role name | END
|
||||
│
|
||||
│ Phase 2: AGENT
|
||||
│ Context: AgentContext = ModeratorCtx + { currentRole: { name, systemPrompt } }
|
||||
│ Action: agent(ctx) → raw string
|
||||
│
|
||||
│ Phase 3: EXTRACTOR
|
||||
│ Context: ExtractContext = AgentCtx + { agentContent }
|
||||
│ Action: extract(schema, extractPrompt, ctx) → typed meta
|
||||
│
|
||||
│ Merge: RoleStep { role, content, meta, timestamp }
|
||||
│ Append to steps
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Context Types (progressive)
|
||||
|
||||
```typescript
|
||||
// Phase 1: Moderator sees accumulated state only
|
||||
type ModeratorContext<M> = {
|
||||
threadId: string;
|
||||
start: StartStep;
|
||||
steps: RoleStep<M>[];
|
||||
};
|
||||
|
||||
// Phase 2: Agent knows its identity
|
||||
type AgentContext<M> = ModeratorContext<M> & {
|
||||
currentRole: { name: string; systemPrompt: string };
|
||||
};
|
||||
|
||||
// Phase 3: Extractor has agent output
|
||||
type ExtractContext<M> = AgentContext<M> & {
|
||||
agentContent: string;
|
||||
};
|
||||
|
||||
// ThreadContext is an alias for AgentContext (backward compat)
|
||||
type ThreadContext<M> = AgentContext<M>;
|
||||
```
|
||||
|
||||
### Key Properties
|
||||
|
||||
- **Moderator is synchronous and pure** — no I/O, no state mutation
|
||||
- **Agent gets context, not instructions** — reads `ctx.currentRole.systemPrompt`
|
||||
- **Extractor is a general tool** — not limited to post-agent extraction; agents can use it too (e.g. Cursor agent extracts workspace path before execution)
|
||||
- **extractPrompt is a call parameter**, not context state — different callers use different prompts
|
||||
|
||||
## Agent Information Sources
|
||||
|
||||
An agent has exactly three information sources:
|
||||
|
||||
1. **Prior knowledge** — LLM training, agent memory, agent skills
|
||||
2. **Thread context** — `AgentContext` (start, steps, currentRole)
|
||||
3. **Derived information** — from 1 & 2 (e.g. tool calls, shell commands)
|
||||
|
||||
No hidden environment parameters. If an agent needs something (like a workspace path), it extracts it from context using `ExtractFn`.
|
||||
|
||||
## Bundle Contract
|
||||
|
||||
A workflow bundle is a single `.esm.js` file with two named exports:
|
||||
|
||||
```typescript
|
||||
// Named exports (no default export)
|
||||
export const descriptor: WorkflowDescriptor;
|
||||
export const run: WorkflowFn;
|
||||
|
||||
type WorkflowFn = (
|
||||
input: { prompt: string; steps: RoleOutput[] },
|
||||
options: { threadId: string; maxRounds: number },
|
||||
) => AsyncGenerator<RoleOutput, WorkflowResult>;
|
||||
```
|
||||
|
||||
### Constraints
|
||||
|
||||
- Single `.esm.js` file
|
||||
- No dynamic `import()`
|
||||
- All static imports must be Node built-in modules only
|
||||
- XXH64 hash (Crockford Base32) = globally unique version ID
|
||||
|
||||
### Why AsyncGenerator?
|
||||
|
||||
- Each `yield` → engine writes to `.data.jsonl`, checks abort/pause
|
||||
- `return` → engine marks thread complete
|
||||
- Fork = pass historical steps as `input.steps` to a new generator
|
||||
- Zero injection — bundle doesn't import from the engine
|
||||
|
||||
## Storage Layout
|
||||
|
||||
```
|
||||
~/.uncaged/workflow/
|
||||
├── bundles/
|
||||
│ ├── C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64
|
||||
│ └── C9NMV6V2TQT81.yaml # Role descriptor
|
||||
├── logs/ # One folder per bundle hash
|
||||
│ └── C9NMV6V2TQT81/
|
||||
│ ├── 01KQXKW…YG.data.jsonl # Thread state
|
||||
│ └── 01KQXKW…YG.info.jsonl # Debug log
|
||||
└── workflow.yaml # Registry
|
||||
```
|
||||
|
||||
### ID Encoding: Crockford Base32
|
||||
|
||||
- Case-insensitive, filesystem-safe, no ambiguous chars (0/O, 1/I/L)
|
||||
- Bundle hash: XXH64 → 13-char
|
||||
- Thread ID: ULID → 26-char (10 timestamp + 16 random)
|
||||
|
||||
### Registry (`workflow.yaml`)
|
||||
|
||||
```yaml
|
||||
workflows:
|
||||
solve-issue:
|
||||
hash: "C9NMV6V2TQT81"
|
||||
timestamp: 1714963200000
|
||||
history:
|
||||
- hash: "A7BKR3M1NPQ40"
|
||||
timestamp: 1714876800000
|
||||
```
|
||||
|
||||
### Thread JSONL
|
||||
|
||||
**`.data.jsonl`** — Line 1: start record, Line 2+: role outputs
|
||||
|
||||
```jsonc
|
||||
// Start record
|
||||
{ "name": "solve-issue", "hash": "C9NMV6V2TQT81", "threadId": "01KQXKW…",
|
||||
"parameters": { "prompt": "Fix bug #3", "options": { "maxRounds": 5 } },
|
||||
"timestamp": 1714963200000 }
|
||||
// Role output
|
||||
{ "role": "planner", "content": "...", "meta": { "phases": [...] }, "timestamp": ... }
|
||||
```
|
||||
|
||||
**`.info.jsonl`** — Structured debug log
|
||||
|
||||
```jsonc
|
||||
{ "tag": "4KNMR2PX", "content": "Loading bundle...", "timestamp": ... }
|
||||
```
|
||||
|
||||
Tags are 8-char Crockford Base32 (40-bit random), one per call site. `grep "4KNMR2PX"` → instant code location.
|
||||
|
||||
## Execution Model
|
||||
|
||||
- **No daemon.** `uncaged-workflow run <name>` starts a worker process
|
||||
- Same bundle's threads share one process (memory efficiency)
|
||||
- Process exits when all threads complete
|
||||
- Thread termination via IPC within the process
|
||||
|
||||
## CLI Commands
|
||||
|
||||
| Priority | Command | Description |
|
||||
|----------|---------|-------------|
|
||||
| P1 | `add <name> <file.esm.js>` | Register a bundle |
|
||||
| P1 | `list` | List registered workflows |
|
||||
| P1 | `show <name>` | Show workflow details |
|
||||
| P1 | `remove <name>` | Remove a workflow |
|
||||
| P1 | `run <name> [--prompt] [--max-rounds]` | Start a thread |
|
||||
| P1 | `threads [name]` | List threads |
|
||||
| P1 | `thread <id>` | Show thread state |
|
||||
| P1 | `thread rm <id>` | Delete a thread |
|
||||
| P1 | `ps` | List running threads |
|
||||
| P1 | `kill <thread-id>` | Terminate a running thread |
|
||||
| P2 | `history <name>` | Show version history |
|
||||
| P2 | `rollback <name> [hash]` | Switch to a previous version |
|
||||
| P2 | `pause <thread-id>` | Pause a running thread |
|
||||
| P2 | `resume <thread-id>` | Resume a paused thread |
|
||||
| P3 | `fork <thread-id> [--from-role <role>]` | Fork from historical state |
|
||||
|
||||
All commands implemented and tested. ✅
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| **Role = pure data** | Decouples definition from execution; same role with different agents |
|
||||
| **Agent bound at runtime** | WorkflowDefinition is reusable; agent choice is deployment concern |
|
||||
| **Three-phase context** | Each phase sees only what it needs; clean separation |
|
||||
| **ExtractFn as general tool** | Agents use it for pre-execution extraction; engine uses it for meta |
|
||||
| **Single-file ESM** | Hash = version, no dependency hell, self-contained |
|
||||
| **No daemon** | OS handles process lifecycle; unnecessary complexity |
|
||||
| **Crockford Base32** | Filesystem-safe, readable, compact |
|
||||
| **No concurrency in registry** | Different workflows have different constraints; belongs at workflow/role level |
|
||||
| **No dryRun** | Tests use mock agents + mock fetch; simpler architecture |
|
||||
@@ -1,315 +0,0 @@
|
||||
# Workflow-as-Agent Implementation Plan
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Enable workflows to invoke other workflows as agents, backed by global CAS and refs tracking.
|
||||
|
||||
**Architecture:** Migrate CAS from thread-local to global (`~/.uncaged/workflow/cas/`), add `refs` to RoleStep for GC traceability, then build `workflowAsAgent(name)` factory that resolves workflow name → bundle via registry and spawns a child thread.
|
||||
|
||||
**Tech Stack:** TypeScript, Bun, Zod v4, monorepo with `packages/`
|
||||
|
||||
**Issue:** https://git.shazhou.work/uncaged/workflow/issues/25
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Global CAS Migration
|
||||
|
||||
Move CAS storage from `<threadDir>/<threadId>.cas/` to `~/.uncaged/workflow/cas/` (global, content-addressed, immutable). This is a **breaking change** — thread-local `.cas/` directories are abandoned.
|
||||
|
||||
### Task 1.1: Add `globalCasDir` helper to `storage-root.ts`
|
||||
|
||||
**Objective:** Provide a single function that returns the global CAS directory path.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/workflow/src/storage-root.ts`
|
||||
- Test: `packages/workflow/__tests__/storage-root.test.ts`
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
// storage-root.ts — add export
|
||||
export function getGlobalCasDir(storageRoot?: string): string {
|
||||
const root = storageRoot ?? getDefaultWorkflowStorageRoot();
|
||||
return join(root, "cas");
|
||||
}
|
||||
```
|
||||
|
||||
Export from `packages/workflow/src/index.ts`.
|
||||
|
||||
### Task 1.2: Update `cmd-cas.ts` to use global CAS
|
||||
|
||||
**Objective:** CLI `cas get/put/list/rm` no longer needs threadId for storage location — CAS is global. But keep threadId in CLI for backward compat of planner/coder prompts (they pass threadId).
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/cli-workflow/src/cmd-cas.ts`
|
||||
|
||||
**Changes:**
|
||||
- `resolveCasDir` → use `getGlobalCasDir(storageRoot)` instead of deriving from thread data path
|
||||
- `cmdCasPut` / `cmdCasGet` / `cmdCasList` / `cmdCasRm`: threadId is still accepted (prompts pass it) but storage goes to global dir
|
||||
- Remove the `resolveThreadDataPath` dependency for CAS operations — thread doesn't need to exist to read CAS
|
||||
|
||||
```typescript
|
||||
import { createThreadCas, getGlobalCasDir } from "@uncaged/workflow";
|
||||
|
||||
export async function cmdCasGet(
|
||||
storageRoot: string,
|
||||
_threadId: string, // kept for CLI compat, not used for path
|
||||
hash: string,
|
||||
): Promise<Result<string, string>> {
|
||||
const cas = createThreadCas(getGlobalCasDir(storageRoot));
|
||||
const content = await cas.get(hash);
|
||||
if (content === null) {
|
||||
return err(`cas entry not found: ${hash}`);
|
||||
}
|
||||
return ok(content);
|
||||
}
|
||||
// ... same pattern for put/list/rm
|
||||
```
|
||||
|
||||
### Task 1.3: Update `cmd-thread.ts` — thread rm no longer deletes `.cas/`
|
||||
|
||||
**Objective:** Since CAS is global, `thread rm` should NOT delete CAS entries. CAS cleanup is GC's job.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/cli-workflow/src/cmd-thread.ts`
|
||||
- Check: remove any `rmdir` / `unlink` of `<threadId>.cas/` directory
|
||||
|
||||
### Task 1.4: Rename `createThreadCas` → `createCasStore`
|
||||
|
||||
**Objective:** The name `createThreadCas` is misleading now. Rename to `createCasStore`.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/workflow/src/cas.ts` — rename function
|
||||
- Modify: `packages/workflow/src/index.ts` — update export (keep `createThreadCas` as deprecated alias for one release)
|
||||
- Modify: all consumers (`cmd-cas.ts`)
|
||||
|
||||
### Task 1.5: Update tests
|
||||
|
||||
**Objective:** All CAS-related tests use global dir instead of thread-local.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/cli-workflow/__tests__/commands.test.ts`
|
||||
- Verify: `bun test` passes
|
||||
|
||||
### Task 1.6: Clean up old thread-local `.cas/` references
|
||||
|
||||
**Objective:** Remove dead code that creates/reads thread-local `.cas/` directories.
|
||||
|
||||
**Files:**
|
||||
- Search all `*.ts` for `.cas` path construction patterns
|
||||
- Remove orphaned helpers
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: RoleStep `refs` Tracking
|
||||
|
||||
Add `refs: string[]` to persisted role steps so GC can trace which CAS entries are alive.
|
||||
|
||||
### Task 2.1: Add `refs` to `RoleOutput` and engine persistence
|
||||
|
||||
**Objective:** Every role step can declare which CAS hashes it produced or consumed.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/workflow/src/types.ts`
|
||||
- Modify: `packages/workflow/src/engine.ts`
|
||||
|
||||
**Changes to `types.ts`:**
|
||||
|
||||
```typescript
|
||||
export type RoleOutput = {
|
||||
role: string;
|
||||
content: string;
|
||||
meta: Record<string, unknown>;
|
||||
refs: string[]; // CAS hashes produced/consumed by this step
|
||||
};
|
||||
```
|
||||
|
||||
**Changes to `engine.ts`:**
|
||||
- `appendDataLine` for role steps: include `refs` field (default `[]` if not provided)
|
||||
|
||||
### Task 2.2: Auto-populate refs from meta hashes
|
||||
|
||||
**Objective:** The engine should automatically extract CAS hashes from `meta` to populate `refs`, so roles don't need to manually track them.
|
||||
|
||||
**Strategy:** After meta extraction, walk the meta object and collect any string that looks like a CAS hash (Crockford Base32, 13 chars). This is a heuristic but works because CAS hashes are distinctive.
|
||||
|
||||
Alternative (simpler): Let each `RoleDefinition` optionally declare a `extractRefs(meta: M) => string[]` function. For planner, this returns `meta.phases.map(p => p.hash)`. For coder, `[meta.completedPhase]`.
|
||||
|
||||
**Recommended:** The explicit `extractRefs` approach — no magic, no false positives.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/workflow/src/types.ts` — add optional `extractRefs` to `RoleDefinition`
|
||||
- Modify: `packages/workflow/src/create-workflow.ts` — call `extractRefs` after meta extraction, set on `RoleOutput.refs`
|
||||
- Modify: `packages/workflow-role-planner/src/planner.ts` — implement `extractRefs`
|
||||
- Modify: `packages/workflow-role-coder/src/coder.ts` — implement `extractRefs`
|
||||
|
||||
```typescript
|
||||
// types.ts — RoleDefinition addition
|
||||
export type RoleDefinition<Meta extends Record<string, unknown>> = {
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
extractPrompt: string;
|
||||
schema: z.ZodType<Meta>;
|
||||
extractRefs?: (meta: Meta) => string[]; // CAS hashes to track
|
||||
};
|
||||
|
||||
// planner.ts
|
||||
extractRefs: (meta) => meta.phases.map(p => p.hash),
|
||||
|
||||
// coder.ts
|
||||
extractRefs: (meta) => [meta.completedPhase],
|
||||
```
|
||||
|
||||
### Task 2.3: Update fork logic to preserve refs
|
||||
|
||||
**Objective:** When forking a thread, `refs` from historical steps must be carried over.
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/workflow/src/fork-thread.ts`
|
||||
- Verify: `ForkHistoricalStep` / `PrefilledDiskStep` include `refs`
|
||||
|
||||
### Task 2.4: Tests for refs tracking
|
||||
|
||||
**Files:**
|
||||
- Add: `packages/workflow/__tests__/refs-tracking.test.ts`
|
||||
- Verify: refs appear in `.data.jsonl` output
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: CAS Garbage Collection
|
||||
|
||||
### Task 3.1: Implement `gc.ts` in `@uncaged/workflow`
|
||||
|
||||
**Objective:** Mark-and-sweep GC — scan all thread `.data.jsonl` files, collect `refs`, delete orphaned CAS entries.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/workflow/src/gc.ts`
|
||||
- Export from: `packages/workflow/src/index.ts`
|
||||
|
||||
```typescript
|
||||
export type GcResult = {
|
||||
scannedThreads: number;
|
||||
activeRefs: number;
|
||||
deletedEntries: number;
|
||||
deletedHashes: string[];
|
||||
};
|
||||
|
||||
export async function garbageCollectCas(storageRoot: string): Promise<GcResult> {
|
||||
// 1. Find all .data.jsonl files under storageRoot
|
||||
// 2. Parse each, flatMap step.refs → Set<string>
|
||||
// 3. List all CAS entries via createCasStore(globalCasDir).list()
|
||||
// 4. Delete entries not in active set
|
||||
// 5. Return stats
|
||||
}
|
||||
```
|
||||
|
||||
### Task 3.2: Add `uncaged-workflow gc` CLI command
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/cli-workflow/src/cmd-gc.ts`
|
||||
- Modify: `packages/cli-workflow/src/cli-dispatch.ts` — add `gc` subcommand
|
||||
|
||||
### Task 3.3: Run GC on `thread rm`
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/cli-workflow/src/cmd-thread.ts` — after deleting thread data, optionally run GC
|
||||
|
||||
### Task 3.4: Tests for GC
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/cli-workflow/__tests__/gc-cli.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: `workflowAsAgent` Factory
|
||||
|
||||
### Task 4.1: Create `workflowAsAgent` in `@uncaged/workflow`
|
||||
|
||||
**Objective:** Factory function that takes a workflow name, resolves to bundle, returns an `AgentFn`.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/workflow/src/workflow-as-agent.ts`
|
||||
- Export from: `packages/workflow/src/index.ts`
|
||||
|
||||
```typescript
|
||||
import type { AgentFn } from "./types.js";
|
||||
|
||||
export type WorkflowAsAgentOptions = {
|
||||
storageRoot?: string;
|
||||
};
|
||||
|
||||
export function workflowAsAgent(
|
||||
workflowName: string,
|
||||
options?: WorkflowAsAgentOptions,
|
||||
): AgentFn {
|
||||
return async (ctx) => {
|
||||
const storageRoot = options?.storageRoot ?? getDefaultWorkflowStorageRoot();
|
||||
|
||||
// 1. Read registry → resolve name to bundle hash + path
|
||||
const registry = await readWorkflowRegistry(storageRoot);
|
||||
const entry = getRegisteredWorkflow(registry, workflowName);
|
||||
if (entry === null) {
|
||||
return `ERROR: workflow "${workflowName}" not found in registry`;
|
||||
}
|
||||
|
||||
// 2. Load bundle
|
||||
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
|
||||
const bundleExports = await extractBundleExports(bundlePath);
|
||||
|
||||
// 3. Create child thread input from ctx.start.content (parent prompt)
|
||||
const input: ThreadInput = {
|
||||
prompt: ctx.start.content,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
// 4. Generate child threadId
|
||||
const childThreadId = generateUlid();
|
||||
|
||||
// 5. Execute — collect all yields, return final content
|
||||
const io: ExecuteThreadIo = { ... };
|
||||
const result = await executeThread(bundleExports.run, workflowName, input, ...);
|
||||
|
||||
// 6. Return summary as agent content
|
||||
return result.summary;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Task 4.2: System-level depth limit
|
||||
|
||||
**Objective:** Prevent infinite recursion. Track depth via thread metadata, enforce a global max (default 3, configurable in `workflow.yaml`).
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/workflow/src/types.ts` — add `depth` to `WorkflowFnOptions`
|
||||
- Modify: `packages/workflow/src/workflow-as-agent.ts` — increment depth, check limit
|
||||
- Modify: registry or config types for `maxDepth` setting
|
||||
|
||||
### Task 4.3: Tests for workflowAsAgent
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/workflow/__tests__/workflow-as-agent.test.ts`
|
||||
- Test: name resolution, depth limit, child thread execution
|
||||
|
||||
### Task 4.4: Integration test — nested workflow
|
||||
|
||||
**Objective:** Create a minimal test workflow that calls another workflow via `workflowAsAgent`.
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/workflow/__tests__/workflow-as-agent-integration.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
```
|
||||
Phase 1 (Global CAS) → Phase 2 (refs) → Phase 3 (GC) → Phase 4 (workflowAsAgent)
|
||||
```
|
||||
|
||||
Each phase is independently mergeable. Phase 3 depends on Phase 2 (needs refs to know what's alive). Phase 4 depends on Phase 1 (global CAS for cross-thread sharing).
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
- CAS storage location moves from `<thread>.cas/` to `~/.uncaged/workflow/cas/`
|
||||
- `RoleOutput` gains required `refs: string[]` field
|
||||
- Existing threads with thread-local CAS will lose access to old CAS data (acceptable — those are short-lived workflow artifacts)
|
||||
- `createThreadCas` renamed to `createCasStore` (alias kept temporarily)
|
||||
@@ -0,0 +1,438 @@
|
||||
# RFC-001: Workflow Engine Design
|
||||
|
||||
**Author:** 小橘 🍊(NEKO Team)
|
||||
**Date:** 2026-05-06
|
||||
**Status:** Draft
|
||||
|
||||
---
|
||||
|
||||
## 1. Package Structure
|
||||
|
||||
| Package | npm Name | Binary |
|
||||
|---------|----------|--------|
|
||||
| Core lib | `@uncaged/workflow` | — |
|
||||
| CLI | `@uncaged/cli-workflow` | `uncaged-workflow` |
|
||||
|
||||
Future: `@uncaged/cli` umbrella, invoke via `uncaged workflow <subcommand>`.
|
||||
|
||||
Monorepo uses **bun workspace**.
|
||||
|
||||
## 2. Workflow Physical Implementation
|
||||
|
||||
A **Workflow** is a single-file ESM module that **named-exports** an **AsyncGenerator** function as `run` and workflow metadata as `descriptor`:
|
||||
|
||||
```typescript
|
||||
/** What each yield produces — one role's output. */
|
||||
type RoleOutput = {
|
||||
role: string;
|
||||
content: string;
|
||||
meta: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** What the generator returns when done. */
|
||||
type WorkflowResult = {
|
||||
returnCode: number;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
/** Input to a workflow — prompt + optional historical steps for fork/resume. */
|
||||
type ThreadInput = {
|
||||
prompt: string;
|
||||
steps: RoleOutput[]; // [] for new thread, pre-filled for fork/resume
|
||||
};
|
||||
|
||||
/** The bundle contract — an AsyncGenerator, not a Promise. */
|
||||
type WorkflowFn = (
|
||||
input: ThreadInput,
|
||||
options: { isDryRun: boolean; maxRounds: number }
|
||||
) => AsyncGenerator<RoleOutput, WorkflowResult>;
|
||||
```
|
||||
|
||||
### Why AsyncGenerator?
|
||||
|
||||
The workflow **yields** each role output instead of writing to an injected writer or
|
||||
exporting a framework-specific shape:
|
||||
|
||||
```typescript
|
||||
// Example bundle — zero framework dependency (named exports only)
|
||||
export const descriptor = {
|
||||
description: "Fix auth bug",
|
||||
roles: {
|
||||
planner: {
|
||||
description: "Plans the fix",
|
||||
schema: { type: "object", properties: { files: { type: "array", items: { type: "string" } } } },
|
||||
},
|
||||
coder: {
|
||||
description: "Implements the plan",
|
||||
schema: { type: "object", properties: { diff: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const run = async function* (input, options) {
|
||||
const plan = await callLLM("plan: " + input.prompt);
|
||||
yield { role: "planner", content: plan, meta: { files: ["src/auth.ts"] } };
|
||||
|
||||
const code = await callLLM("implement: " + plan);
|
||||
yield { role: "coder", content: code, meta: { diff: "..." } };
|
||||
|
||||
return { returnCode: 0, summary: "Fixed auth bug" };
|
||||
};
|
||||
```
|
||||
|
||||
**Engine controls the loop**, not the bundle:
|
||||
- Each `yield` → engine writes to `.data.jsonl`, checks `AbortSignal`, handles pause/resume
|
||||
- `return` → engine writes the final result, marks thread complete
|
||||
- **Fork** = read historical steps from `.data.jsonl`, pass as `input.steps` to a new generator
|
||||
- **Zero injection** — the bundle doesn't import or receive anything from the engine
|
||||
|
||||
### Fork/Resume via ThreadInput
|
||||
|
||||
When using the `createRoleModerator` helper, fork is **naturally handled**:
|
||||
|
||||
```typescript
|
||||
// The moderator receives ThreadContext with historical steps
|
||||
// It sees planner already ran → routes to coder automatically
|
||||
const gen = workflow(
|
||||
{ prompt: "fix bug #3", steps: [{ role: "planner", content: "...", meta: {} }] },
|
||||
{ isDryRun: false, maxRounds: 10 }
|
||||
);
|
||||
// First yield will be coder's output, not planner's
|
||||
```
|
||||
|
||||
No special replay logic needed — the moderator/role pattern inherently supports
|
||||
resuming from any snapshot, because moderator routing is a pure function of the
|
||||
accumulated steps.
|
||||
|
||||
This follows the **Dependency Inversion Principle**: the engine depends on the
|
||||
generator protocol (a language primitive), not on a framework-specific `WorkflowDefinition`.
|
||||
Bundles remain pure functions with no coupling to `@uncaged/workflow`.
|
||||
|
||||
### Relationship to Role/Moderator Pattern
|
||||
|
||||
The Role + Moderator pattern from Section 8 is one **implementation strategy** inside a
|
||||
bundle, not the bundle contract itself. A helper like `createRoleModerator(roles, moderator)`
|
||||
can produce the AsyncGenerator internally, but simple workflows can yield directly without
|
||||
any framework types.
|
||||
|
||||
### Constraints
|
||||
|
||||
- Single `.esm.js` file
|
||||
- Named exports `run` (callable AsyncGenerator workflow) and `descriptor` (metadata object)
|
||||
- No default export
|
||||
- No dynamic `import()`
|
||||
- All static imports must be Node built-in modules only
|
||||
|
||||
This guarantees the file is self-contained, and its **XXH64 hash** (encoded as Crockford Base32) serves as a globally unique version identifier.
|
||||
|
||||
### Role Descriptor (`export const descriptor`)
|
||||
|
||||
The bundle **must** export a `descriptor` object describing roles for tooling/agent consumption.
|
||||
|
||||
Shape: `{ description: string, roles: Record<string, { description: string, schema: JSONSchema }> }`
|
||||
|
||||
When you register a bundle via `uncaged-workflow add`, the engine imports the module, validates `descriptor`, and writes `{hash}.yaml` next to `{hash}.esm.js` under `bundles/` (same serialized shape as below):
|
||||
|
||||
```yaml
|
||||
description: "Workflow brief introduction"
|
||||
roles:
|
||||
planner:
|
||||
description: "Analyzes the issue and creates a plan"
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
plan:
|
||||
type: string
|
||||
files:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
coder:
|
||||
description: "Implements the plan"
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
diff:
|
||||
type: string
|
||||
```
|
||||
|
||||
Execution uses `run` only; YAML is for tooling and introspection.
|
||||
|
||||
## 3. Storage Layout
|
||||
|
||||
All data lives under `~/.uncaged/workflow/`:
|
||||
|
||||
```
|
||||
~/.uncaged/workflow/
|
||||
├── bundles/ # ESM bundles
|
||||
│ ├── C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64 hash
|
||||
│ └── C9NMV6V2TQT81.yaml # Role descriptor (from bundle export, at register time)
|
||||
├── logs/ # Thread data, one folder per bundle hash
|
||||
│ └── C9NMV6V2TQT81/
|
||||
│ ├── 01KQXKW18CT8G75T53R8F4G7YG.data.jsonl
|
||||
│ └── 01KQXKW18CT8G75T53R8F4G7YG.info.jsonl
|
||||
└── workflow.yaml # Registry
|
||||
```
|
||||
|
||||
**Not** a git repo. **Not** an npm package. Bundles are self-contained single files.
|
||||
|
||||
### ID Encoding
|
||||
|
||||
All IDs use **Crockford Base32**:
|
||||
- Better readability than Base64
|
||||
- Higher density than hex (shorter filenames)
|
||||
- ULID: 10 chars timestamp (high 2 bits zero-padded for future use) + 16 chars random
|
||||
|
||||
## 4. Registry (`workflow.yaml`)
|
||||
|
||||
```yaml
|
||||
workflows:
|
||||
solve-issue:
|
||||
hash: "C9NMV6V2TQT81"
|
||||
timestamp: 1714963200000
|
||||
history:
|
||||
- hash: "A7BKR3M1NPQ40"
|
||||
timestamp: 1714876800000
|
||||
- hash: "X2FGH8J4KLM56"
|
||||
timestamp: 1714790400000
|
||||
```
|
||||
|
||||
Type:
|
||||
|
||||
```typescript
|
||||
{
|
||||
workflows: Record<string, {
|
||||
hash: string; // Crockford Base32 of current XXH64
|
||||
timestamp: number;
|
||||
history: { hash: string; timestamp: number }[];
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
No concurrency control or timeout settings in the registry — those belong to each workflow/role/adapter.
|
||||
|
||||
## 5. Thread JSONL Format
|
||||
|
||||
### `.data.jsonl` — Thread State
|
||||
|
||||
**Line 1: Start record**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "solve-issue",
|
||||
"hash": "C9NMV6V2TQT81",
|
||||
"threadId": "01KQXKW18CT8G75T53R8F4G7YG",
|
||||
"parameters": {
|
||||
"prompt": "Fix the login redirect bug in #3",
|
||||
"options": {
|
||||
"isDryRun": false,
|
||||
"maxRounds": 5
|
||||
}
|
||||
},
|
||||
"timestamp": 1714963200000
|
||||
}
|
||||
```
|
||||
|
||||
**Line 2+: Role outputs**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"role": "planner",
|
||||
"content": "Plan: modify auth middleware...",
|
||||
"meta": { "plan": "...", "files": ["src/auth.ts"] },
|
||||
"timestamp": 1714963201000
|
||||
}
|
||||
```
|
||||
|
||||
### `.info.jsonl` — Debug Log
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"tag": "4KNMR2PX", // 40-bit random, Crockford Base32 (8 chars)
|
||||
"content": "Loading workflow bundle...",
|
||||
"timestamp": 1714963200500
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Execution Model
|
||||
|
||||
- **No daemon.** `uncaged-workflow run <name>` starts a worker process.
|
||||
- Same bundle's threads share one process (memory efficiency).
|
||||
- Process exits automatically when all threads complete.
|
||||
- Thread termination requires **IPC** within the process (not just kill PID).
|
||||
|
||||
## 7. CLI Requirements
|
||||
|
||||
### P1 (Must Have)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uncaged-workflow add <name> <file.esm.js> [--types <path>]` | Register a compiled `.esm.js` bundle (descriptor extracted from `export const descriptor`) |
|
||||
| `uncaged-workflow list` | List registered workflows |
|
||||
| `uncaged-workflow show <name>` | Show workflow details |
|
||||
| `uncaged-workflow remove <name>` | Remove a workflow |
|
||||
| `uncaged-workflow run <name> [--prompt] [--dry-run] [--max-rounds]` | Start a thread |
|
||||
| `uncaged-workflow threads [name]` | List threads (optionally filter by workflow) |
|
||||
| `uncaged-workflow thread <id>` | Show thread state |
|
||||
| `uncaged-workflow thread rm <id>` | Delete a thread |
|
||||
| `uncaged-workflow ps` | List running threads |
|
||||
| `uncaged-workflow kill <thread-id>` | Terminate a running thread (via IPC) |
|
||||
|
||||
### P2 (Should Have)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uncaged-workflow history <name>` | Show version history |
|
||||
| `uncaged-workflow rollback <name> [hash]` | Switch to a previous version |
|
||||
| `uncaged-workflow pause <thread-id>` | Pause a running thread |
|
||||
| `uncaged-workflow resume <thread-id>` | Resume a paused thread |
|
||||
|
||||
### P3 (Nice to Have)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `uncaged-workflow fork <thread-id> [--from-role <role>]` | Fork from a historical thread state |
|
||||
|
||||
## 8. Role/Moderator Pattern (Helper, Not Contract)
|
||||
|
||||
The bundle contract is the AsyncGenerator from Section 2. The Role + Moderator pattern
|
||||
below is a **convenience helper** for the common case of multi-role workflows with a
|
||||
routing function. It lives in `@uncaged/workflow` as an optional utility.
|
||||
|
||||
### Helper Function
|
||||
|
||||
```typescript
|
||||
function createRoleModerator<M extends RoleMeta>(
|
||||
def: { roles: { [K in keyof M & string]: Role<M[K]> }; moderator: Moderator<M> }
|
||||
): WorkflowFn; // returns (input: ThreadInput, options) => AsyncGenerator
|
||||
```
|
||||
|
||||
Usage in a bundle:
|
||||
|
||||
```typescript
|
||||
import { createRoleModerator, END } from "@uncaged/workflow";
|
||||
|
||||
export const descriptor = {
|
||||
description: "Example multi-role workflow",
|
||||
roles: {
|
||||
planner: { description: "Plans work", schema: {} },
|
||||
coder: { description: "Writes code", schema: {} },
|
||||
},
|
||||
};
|
||||
|
||||
export const run = createRoleModerator({
|
||||
roles: { planner, coder },
|
||||
moderator(ctx) { return ctx.steps.length === 0 ? "planner" : END; },
|
||||
});
|
||||
// Accepts ThreadInput — fork with pre-filled steps works automatically
|
||||
```
|
||||
|
||||
### Supporting Types
|
||||
|
||||
```typescript
|
||||
/** Sentinel values for automaton control flow. */
|
||||
const START = "__start__" as const;
|
||||
const END = "__end__" as const;
|
||||
|
||||
/** Maps role names → their meta types. Single generic drives all inference. */
|
||||
type RoleMeta = Record<string, Record<string, unknown>>;
|
||||
|
||||
/** Typed output of a Role execution. */
|
||||
type RoleResult<Meta> = { content: string; meta: Meta };
|
||||
|
||||
/** Engine start frame: initial prompt + thread identity. */
|
||||
type StartStep = {
|
||||
role: START;
|
||||
content: string; // the user prompt
|
||||
meta: { maxRounds: number; threadId: string };
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
/** A completed role step in the thread. */
|
||||
type RoleStep<M extends RoleMeta> = {
|
||||
[K in keyof M & string]: { role: K; meta: M[K]; content: string; timestamp: number };
|
||||
}[keyof M & string];
|
||||
|
||||
/** Thread-scoped context passed to roles and moderator. */
|
||||
type ThreadContext<M extends RoleMeta = RoleMeta> = {
|
||||
threadId: string;
|
||||
start: StartStep;
|
||||
steps: RoleStep<M>[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A Role — receives full thread context, returns typed content + meta.
|
||||
* Implementation can be an agent, LLM call, script, HTTP request, etc.
|
||||
*/
|
||||
type Role<Meta> = (ctx: ThreadContext) => Promise<RoleResult<Meta>>;
|
||||
|
||||
/**
|
||||
* An Agent — raw string output interface for LLM/CLI adapters.
|
||||
* Structured meta is extracted by the role's extract layer.
|
||||
*/
|
||||
type AgentFn = (ctx: ThreadContext, systemPrompt: string) => Promise<string>;
|
||||
|
||||
/**
|
||||
* The Moderator — a pure routing function.
|
||||
* Receives the full thread context (start + all prior steps).
|
||||
* On initial call, `steps` is empty.
|
||||
* Returns the next role name or END to terminate.
|
||||
*/
|
||||
type Moderator<M extends RoleMeta> = (ctx: ThreadContext<M>) => (keyof M & string) | END;
|
||||
|
||||
/** Complete workflow definition as authored by users. */
|
||||
type WorkflowDefinition<M extends RoleMeta> = {
|
||||
name: string;
|
||||
roles: { [K in keyof M & string]: Role<M[K]> };
|
||||
moderator: Moderator<M>;
|
||||
};
|
||||
```
|
||||
|
||||
### Execution Flow (when using createRoleModerator)
|
||||
|
||||
```
|
||||
START (prompt) → Moderator → Role A → Moderator → Role B → ... → Moderator → END
|
||||
```
|
||||
|
||||
1. Engine creates a `StartStep` with the user prompt and maxRounds
|
||||
2. Moderator is called with `steps = []`, returns the first role name
|
||||
3. Role executes, appends a `RoleStep` to the thread
|
||||
4. Moderator is called again with updated steps, returns next role or END
|
||||
5. Repeat until END or maxRounds reached
|
||||
|
||||
### Responsibilities
|
||||
|
||||
| Component | Responsibility | Purity |
|
||||
|-----------|---------------|--------|
|
||||
| **Moderator** | Route to next role based on thread state | Pure function, no side effects |
|
||||
| **Role** | Execute a step (call LLM, run script, etc.) | Async, may have side effects |
|
||||
| **AgentFn** | Low-level LLM/CLI invocation adapter | Async, side effects |
|
||||
|
||||
### Key Constraints
|
||||
|
||||
- Moderator is **synchronous and pure** — no I/O, no state mutation
|
||||
- Roles receive the **full thread context** (not just the last message)
|
||||
- Round count = `steps.length`; max rounds in `start.meta.maxRounds`
|
||||
- The `meta` field on each step is **typed per role** via the `RoleMeta` generic
|
||||
|
||||
## 9. Design Decisions & Rationale
|
||||
|
||||
### Why single-file ESM?
|
||||
- Hash = version. No ambiguity.
|
||||
- No dependency hell. Self-contained.
|
||||
- Simple to distribute, store, and verify.
|
||||
|
||||
### Why no daemon?
|
||||
- Unnecessary complexity for process-per-bundle model.
|
||||
- OS process management (systemd, etc.) handles restarts.
|
||||
- IPC within process handles thread lifecycle.
|
||||
|
||||
### Why Crockford Base32?
|
||||
- Case-insensitive, filesystem-safe.
|
||||
- No ambiguous characters (0/O, 1/I/L).
|
||||
- More compact than hex (13 chars for 64-bit vs 16).
|
||||
|
||||
### Why not control concurrency in registry?
|
||||
- Different workflows have different constraints.
|
||||
- Same workflow may allow cross-project concurrency but not intra-project.
|
||||
- Concurrency belongs at workflow/role/adapter level.
|
||||
+9
-31
@@ -1,14 +1,9 @@
|
||||
import { createExtract, createWorkflow, END, type RoleDefinition } from "@uncaged/workflow";
|
||||
import * as z from "zod/v4";
|
||||
import { createRoleModerator, END, type Role } from "@uncaged/workflow";
|
||||
|
||||
type Roles = {
|
||||
greeter: { greeting: string };
|
||||
};
|
||||
|
||||
const greeterMetaSchema = z.object({
|
||||
greeting: z.string(),
|
||||
});
|
||||
|
||||
export const descriptor = {
|
||||
description: "A simple hello world workflow",
|
||||
roles: {
|
||||
@@ -23,31 +18,14 @@ export const descriptor = {
|
||||
},
|
||||
};
|
||||
|
||||
const greeter: RoleDefinition<Roles["greeter"]> = {
|
||||
description: "Generates a greeting",
|
||||
systemPrompt: "You greet the user briefly.",
|
||||
extractPrompt: "Extract the greeting string produced for the user.",
|
||||
schema: greeterMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
};
|
||||
|
||||
const extract = createExtract({
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "",
|
||||
model: "",
|
||||
const greeter: Role<Roles["greeter"]> = async (ctx) => ({
|
||||
content: `Hello, ${ctx.start.content}`,
|
||||
meta: { greeting: "Hello!" },
|
||||
});
|
||||
|
||||
export const run = createWorkflow<Roles>(
|
||||
{
|
||||
roles: { greeter },
|
||||
moderator(ctx) {
|
||||
return ctx.steps.length === 0 ? "greeter" : END;
|
||||
},
|
||||
export const run = createRoleModerator<Roles>({
|
||||
roles: { greeter },
|
||||
moderator(ctx) {
|
||||
return ctx.steps.length === 0 ? "greeter" : END;
|
||||
},
|
||||
{
|
||||
agent: async (ctx) => `Hello, ${ctx.start.content}`,
|
||||
},
|
||||
extract,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
"@uncaged/workflow": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@ import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promise
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { getGlobalCasDir, getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
|
||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
|
||||
import { cmdAdd } from "../src/cmd-add.js";
|
||||
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/cmd-cas.js";
|
||||
import { cmdHistory } from "../src/cmd-history.js";
|
||||
import { cmdList, formatListLines } from "../src/cmd-list.js";
|
||||
import { cmdRemove } from "../src/cmd-remove.js";
|
||||
@@ -16,9 +15,6 @@ import { addCliArgs } from "./bundle-fixture.js";
|
||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} };
|
||||
`;
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
`;
|
||||
|
||||
describe("cli workflow commands", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
@@ -44,13 +40,11 @@ describe("cli workflow commands", () => {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}import fs from "node:fs";
|
||||
`${fixtureDescriptor}import fs from "node:fs";
|
||||
|
||||
export const run = async function* (input, options) {
|
||||
export const run = async function* (input) {
|
||||
fs.existsSync(".");
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, input.prompt);
|
||||
yield { role: "noop", contentHash: h, meta: { done: true }, refs: [h] };
|
||||
yield { role: "noop", content: input.prompt, meta: { done: true } };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
}
|
||||
`,
|
||||
@@ -117,8 +111,8 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
|
||||
const bundlePath = join(storageRoot, "solo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`export const run = async function* () {
|
||||
yield { role: "x", contentHash: "STUBHASH00000000000000001", meta: {}, refs: [] };
|
||||
`export const run = async function* (input) {
|
||||
yield { role: "x", content: input.prompt, meta: {} };
|
||||
return { returnCode: 0, summary: "ok" };
|
||||
}
|
||||
`,
|
||||
@@ -146,11 +140,8 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
|
||||
},
|
||||
},
|
||||
};
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, input.prompt);
|
||||
yield { role: "greeter", contentHash: h, meta: { greeting: "hi" }, refs: [h] };
|
||||
export const run = async function* (input) {
|
||||
yield { role: "greeter", content: input.prompt, meta: { greeting: "hi" } };
|
||||
return { returnCode: 0, summary: "ok" };
|
||||
};
|
||||
`,
|
||||
@@ -188,10 +179,8 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "x", meta: {} };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
`,
|
||||
@@ -219,10 +208,8 @@ export const run = async function* (input, options) {
|
||||
const dtsPath = join(bundleDir, "types.d.ts");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "x", meta: {} };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
`,
|
||||
@@ -252,10 +239,8 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "x", meta: {} };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
`,
|
||||
@@ -275,17 +260,13 @@ export const run = async function* (input, options) {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v1");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
const v1 = `${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "v1", meta: {} };
|
||||
return { returnCode: 0, summary: "v1" };
|
||||
}
|
||||
`;
|
||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v2");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
const v2 = `${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "v2", meta: {} };
|
||||
return { returnCode: 0, summary: "v2" };
|
||||
}
|
||||
`;
|
||||
@@ -317,17 +298,13 @@ export const run = async function* (input, options) {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v1");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
const v1 = `${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "v1", meta: {} };
|
||||
return { returnCode: 0, summary: "v1" };
|
||||
}
|
||||
`;
|
||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v2");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
const v2 = `${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "v2", meta: {} };
|
||||
return { returnCode: 0, summary: "v2" };
|
||||
}
|
||||
`;
|
||||
@@ -369,10 +346,8 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "x", meta: {} };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
`,
|
||||
@@ -382,10 +357,8 @@ export const run = async function* (input, options) {
|
||||
expect(add1.ok).toBe(true);
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "y");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "y", meta: {} };
|
||||
return { returnCode: 0, summary: "y" };
|
||||
}
|
||||
`,
|
||||
@@ -398,47 +371,14 @@ export const run = async function* (input, options) {
|
||||
expect(bad.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("cas put/get/list/rm use global cas dir (thread id not required for storage)", async () => {
|
||||
const put = await cmdCasPut(storageRoot, "nonexistent-thread-id", "phase doc");
|
||||
expect(put.ok).toBe(true);
|
||||
if (!put.ok) {
|
||||
return;
|
||||
}
|
||||
const hash = put.value;
|
||||
const blobPath = join(getGlobalCasDir(storageRoot), `${hash}.txt`);
|
||||
expect(await readFile(blobPath, "utf8")).toBe("phase doc");
|
||||
|
||||
const got = await cmdCasGet(storageRoot, "other-thread", hash);
|
||||
expect(got.ok).toBe(true);
|
||||
if (!got.ok) {
|
||||
return;
|
||||
}
|
||||
expect(got.value).toBe("phase doc");
|
||||
|
||||
const listed = await cmdCasList(storageRoot, "another-thread");
|
||||
expect(listed.ok).toBe(true);
|
||||
if (!listed.ok) {
|
||||
return;
|
||||
}
|
||||
expect(listed.value).toContain(hash);
|
||||
|
||||
const removed = await cmdCasRm(storageRoot, "rm-thread", hash);
|
||||
expect(removed.ok).toBe(true);
|
||||
|
||||
const missing = await cmdCasGet(storageRoot, "after-rm", hash);
|
||||
expect(missing.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("rollback rejects missing bundle file for target hash", async () => {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "x", meta: {} };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
`,
|
||||
@@ -452,10 +392,8 @@ export const run = async function* (input, options) {
|
||||
const hash1 = add1.value.hash;
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "y");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "y", meta: {} };
|
||||
return { returnCode: 0, summary: "y" };
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, getContentMerklePayload, getGlobalCasDir } from "@uncaged/workflow";
|
||||
import { cmdAdd } from "../src/cmd-add.js";
|
||||
import { cmdFork } from "../src/cmd-fork.js";
|
||||
import { cmdRun } from "../src/cmd-run.js";
|
||||
@@ -10,9 +9,7 @@ import { pathExists } from "../src/fs-utils.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
|
||||
/** Three-role workflow that respects `input.steps` for fork/resume. */
|
||||
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
|
||||
export const descriptor = {
|
||||
const threeRoleBundleSource = `export const descriptor = {
|
||||
description: "fork-cli",
|
||||
roles: {
|
||||
planner: { description: "planner", schema: {} },
|
||||
@@ -20,21 +17,20 @@ export const descriptor = {
|
||||
reviewer: { description: "reviewer", schema: {} },
|
||||
},
|
||||
};
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
export const run = async function* (input) {
|
||||
const has = (r) => input.steps.some((s) => s.role === r);
|
||||
if (!has("planner")) {
|
||||
const h = await putContentMerkleNode(cas, "p1");
|
||||
yield { role: "planner", contentHash: h, meta: { k: "planner" }, refs: [h] };
|
||||
yield { role: "planner", content: "p1", meta: { k: "planner" } };
|
||||
}
|
||||
if (!has("coder")) {
|
||||
const h = await putContentMerkleNode(cas, "c1");
|
||||
yield { role: "coder", contentHash: h, meta: { k: "coder" }, refs: [h] };
|
||||
yield { role: "coder", content: "c1", meta: { k: "coder" } };
|
||||
}
|
||||
if (!has("reviewer")) {
|
||||
const body = "rev-" + String(input.steps.length);
|
||||
const h = await putContentMerkleNode(cas, body);
|
||||
yield { role: "reviewer", contentHash: h, meta: { k: "reviewer" }, refs: [h] };
|
||||
yield {
|
||||
role: "reviewer",
|
||||
content: "rev-" + String(input.steps.length),
|
||||
meta: { k: "reviewer" },
|
||||
};
|
||||
}
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
@@ -102,7 +98,7 @@ describe("cli fork", () => {
|
||||
}
|
||||
const hash = added.value.hash;
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
return;
|
||||
@@ -136,8 +132,7 @@ describe("cli fork", () => {
|
||||
|
||||
const last = JSON.parse(lines[lines.length - 1] ?? "{}") as Record<string, unknown>;
|
||||
expect(last.role).toBe("reviewer");
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
expect(await getContentMerklePayload(cas, String(last.contentHash))).toBe("rev-1");
|
||||
expect(last.content).toBe("rev-1");
|
||||
});
|
||||
|
||||
test("fork without --from-role retries last role", async () => {
|
||||
@@ -153,7 +148,7 @@ describe("cli fork", () => {
|
||||
}
|
||||
const hash = added.value.hash;
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
return;
|
||||
@@ -184,12 +179,11 @@ describe("cli fork", () => {
|
||||
|
||||
const replayCoder = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||
expect(replayCoder.role).toBe("coder");
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
expect(await getContentMerklePayload(cas, String(replayCoder.contentHash))).toBe("c1");
|
||||
expect(replayCoder.content).toBe("c1");
|
||||
|
||||
const last = JSON.parse(lines[lines.length - 1] ?? "{}") as Record<string, unknown>;
|
||||
expect(last.role).toBe("reviewer");
|
||||
expect(await getContentMerklePayload(cas, String(last.contentHash))).toBe("rev-2");
|
||||
expect(last.content).toBe("rev-2");
|
||||
});
|
||||
|
||||
test("fork rejects unknown role with available names", async () => {
|
||||
@@ -204,7 +198,7 @@ describe("cli fork", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
return;
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
createCasStore,
|
||||
garbageCollectCas,
|
||||
getGlobalCasDir,
|
||||
putContentMerkleNode,
|
||||
} from "@uncaged/workflow";
|
||||
import { cmdThreadRemove } from "../src/cmd-thread.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
|
||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
|
||||
async function writeDemoDataJsonl(params: {
|
||||
path: string;
|
||||
threadId: string;
|
||||
bundleHash: string;
|
||||
cas: ReturnType<typeof createCasStore>;
|
||||
activeHash: string;
|
||||
}): Promise<void> {
|
||||
const bodyHash = await putContentMerkleNode(params.cas, "p");
|
||||
const text = [
|
||||
JSON.stringify({
|
||||
name: "demo",
|
||||
hash: params.bundleHash,
|
||||
threadId: params.threadId,
|
||||
parameters: { prompt: "hi", options: { maxRounds: 5 } },
|
||||
timestamp: 100,
|
||||
}),
|
||||
JSON.stringify({
|
||||
role: "planner",
|
||||
contentHash: bodyHash,
|
||||
meta: {},
|
||||
refs: [params.activeHash, bodyHash],
|
||||
timestamp: 101,
|
||||
}),
|
||||
"",
|
||||
].join("\n");
|
||||
await writeFile(params.path, text, "utf8");
|
||||
}
|
||||
|
||||
describe("gc cli and garbageCollectCas", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-gc-"));
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||
});
|
||||
|
||||
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("garbageCollectCas keeps CAS entries referenced by thread refs", async () => {
|
||||
const bundleHash = "C9NMV6V2TQT81";
|
||||
const threadId = "01AAA1111111111111111111";
|
||||
const logsDir = join(storageRoot, "logs", bundleHash);
|
||||
await mkdir(logsDir, { recursive: true });
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const activeHash = await cas.put("active-blob");
|
||||
const orphanHash = await cas.put("orphan-blob");
|
||||
|
||||
await writeDemoDataJsonl({
|
||||
path: join(logsDir, `${threadId}.data.jsonl`),
|
||||
threadId,
|
||||
bundleHash,
|
||||
cas,
|
||||
activeHash,
|
||||
});
|
||||
|
||||
const gc = await garbageCollectCas(storageRoot);
|
||||
expect(gc.ok).toBe(true);
|
||||
if (!gc.ok) {
|
||||
return;
|
||||
}
|
||||
expect(gc.value.scannedThreads).toBe(1);
|
||||
expect(gc.value.activeRefs).toBe(2);
|
||||
expect(gc.value.deletedEntries).toBe(1);
|
||||
expect(gc.value.deletedHashes).toEqual([orphanHash]);
|
||||
|
||||
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${activeHash}.txt`))).toBe(true);
|
||||
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`))).toBe(false);
|
||||
});
|
||||
|
||||
test("garbageCollectCas deletes orphaned CAS when no threads reference them", async () => {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const orphanHash = await cas.put("lonely");
|
||||
|
||||
const gc = await garbageCollectCas(storageRoot);
|
||||
expect(gc.ok).toBe(true);
|
||||
if (!gc.ok) {
|
||||
return;
|
||||
}
|
||||
expect(gc.value.scannedThreads).toBe(0);
|
||||
expect(gc.value.activeRefs).toBe(0);
|
||||
expect(gc.value.deletedEntries).toBe(1);
|
||||
expect(gc.value.deletedHashes).toEqual([orphanHash]);
|
||||
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`))).toBe(false);
|
||||
});
|
||||
|
||||
test("cli gc prints stats", async () => {
|
||||
const bundleHash = "C9NMV6V2TQT81";
|
||||
const threadId = "01BBB2222222222222222222";
|
||||
const logsDir = join(storageRoot, "logs", bundleHash);
|
||||
await mkdir(logsDir, { recursive: true });
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const activeHash = await cas.put("keep-me");
|
||||
await cas.put("drop-me");
|
||||
|
||||
await writeDemoDataJsonl({
|
||||
path: join(logsDir, `${threadId}.data.jsonl`),
|
||||
threadId,
|
||||
bundleHash,
|
||||
cas,
|
||||
activeHash,
|
||||
});
|
||||
|
||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||
const proc = spawnSync(process.execPath, [cliEntryPath, "gc"], { env, encoding: "utf8" });
|
||||
expect(proc.status).toBe(0);
|
||||
expect(String(proc.stdout).trim()).toBe("scanned 1 threads, 2 active refs, deleted 1 entries");
|
||||
});
|
||||
|
||||
test("thread rm triggers gc so unreferenced CAS is removed", async () => {
|
||||
const bundleHash = "C9NMV6V2TQT81";
|
||||
const threadId = "01CCC3333333333333333333";
|
||||
const logsDir = join(storageRoot, "logs", bundleHash);
|
||||
await mkdir(logsDir, { recursive: true });
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const activeHash = await cas.put("pinned-by-ref");
|
||||
await writeDemoDataJsonl({
|
||||
path: join(logsDir, `${threadId}.data.jsonl`),
|
||||
threadId,
|
||||
bundleHash,
|
||||
cas,
|
||||
activeHash,
|
||||
});
|
||||
|
||||
const orphanHash = await cas.put("orphan-after-rm");
|
||||
const orphanPath = join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`);
|
||||
|
||||
const removed = await cmdThreadRemove(storageRoot, threadId);
|
||||
expect(removed.ok).toBe(true);
|
||||
|
||||
expect(await pathExists(orphanPath)).toBe(false);
|
||||
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${activeHash}.txt`))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { runCli } from "../src/cli-dispatch.js";
|
||||
import { cmdInitTemplate, cmdInitWorkspace } from "../src/cmd-init.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
|
||||
describe("init template", () => {
|
||||
let parent: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
parent = join(
|
||||
tmpdir(),
|
||||
`wf-init-template-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
await mkdir(parent, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(parent, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("creates templates/<name> with expected files", async () => {
|
||||
const ws = await cmdInitWorkspace(parent, "my-workflows");
|
||||
expect(ws.ok).toBe(true);
|
||||
if (!ws.ok) {
|
||||
return;
|
||||
}
|
||||
const root = ws.value.rootPath;
|
||||
|
||||
const created = await cmdInitTemplate(root, "review-pr");
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tdir = join(root, "templates", "review-pr");
|
||||
expect(created.value.templatePath).toBe(tdir);
|
||||
expect(await pathExists(join(tdir, "package.json"))).toBe(true);
|
||||
expect(await pathExists(join(tdir, "tsconfig.json"))).toBe(true);
|
||||
expect(await pathExists(join(tdir, "src", "roles.ts"))).toBe(true);
|
||||
expect(await pathExists(join(tdir, "src", "moderator.ts"))).toBe(true);
|
||||
expect(await pathExists(join(tdir, "src", "index.ts"))).toBe(true);
|
||||
|
||||
const pkg = JSON.parse(await readFile(join(tdir, "package.json"), "utf8")) as {
|
||||
name: string;
|
||||
type: string;
|
||||
dependencies: Record<string, string>;
|
||||
};
|
||||
expect(pkg.type).toBe("module");
|
||||
expect(pkg.dependencies["@uncaged/workflow"]).toBeDefined();
|
||||
expect(pkg.dependencies.zod).toBeDefined();
|
||||
expect(pkg.name).toContain("review-pr");
|
||||
|
||||
const idx = await readFile(join(tdir, "src", "index.ts"), "utf8");
|
||||
expect(idx).toContain("WorkflowDefinition");
|
||||
|
||||
const roles = await readFile(join(tdir, "src", "roles.ts"), "utf8");
|
||||
expect(roles).not.toContain("interface ");
|
||||
expect(roles).not.toContain("?:");
|
||||
expect(roles).not.toContain("export default");
|
||||
|
||||
const moder = await readFile(join(tdir, "src", "moderator.ts"), "utf8");
|
||||
expect(moder).not.toContain("export default");
|
||||
});
|
||||
|
||||
test("finds workspace walking up from nested cwd", async () => {
|
||||
const ws = await cmdInitWorkspace(parent, "ws");
|
||||
expect(ws.ok).toBe(true);
|
||||
if (!ws.ok) {
|
||||
return;
|
||||
}
|
||||
const root = ws.value.rootPath;
|
||||
const nested = join(root, "a", "b");
|
||||
await mkdir(nested, { recursive: true });
|
||||
|
||||
const created = await cmdInitTemplate(nested, "nested-tpl");
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) {
|
||||
return;
|
||||
}
|
||||
expect(await pathExists(join(root, "templates", "nested-tpl", "src", "index.ts"))).toBe(true);
|
||||
});
|
||||
|
||||
test("errors when not inside a workflow workspace", async () => {
|
||||
const orphan = join(parent, "nowhere");
|
||||
await mkdir(orphan, { recursive: true });
|
||||
const r = await cmdInitTemplate(orphan, "x");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("templates/*");
|
||||
}
|
||||
});
|
||||
|
||||
test("errors when template directory already exists", async () => {
|
||||
const ws = await cmdInitWorkspace(parent, "ws");
|
||||
expect(ws.ok).toBe(true);
|
||||
if (!ws.ok) {
|
||||
return;
|
||||
}
|
||||
const root = ws.value.rootPath;
|
||||
|
||||
const first = await cmdInitTemplate(root, "dup");
|
||||
expect(first.ok).toBe(true);
|
||||
|
||||
const second = await cmdInitTemplate(root, "dup");
|
||||
expect(second.ok).toBe(false);
|
||||
if (!second.ok) {
|
||||
expect(second.error).toContain("already exists");
|
||||
}
|
||||
});
|
||||
|
||||
test("errors on invalid template name", async () => {
|
||||
const ws = await cmdInitWorkspace(parent, "ws");
|
||||
expect(ws.ok).toBe(true);
|
||||
if (!ws.ok) {
|
||||
return;
|
||||
}
|
||||
const bad = await cmdInitTemplate(ws.value.rootPath, "a/b");
|
||||
expect(bad.ok).toBe(false);
|
||||
});
|
||||
|
||||
test.serial("runCli init template uses cwd and succeeds in workspace", async () => {
|
||||
const ws = await cmdInitWorkspace(parent, "cli-ws");
|
||||
expect(ws.ok).toBe(true);
|
||||
if (!ws.ok) {
|
||||
return;
|
||||
}
|
||||
const root = ws.value.rootPath;
|
||||
const prev = process.cwd();
|
||||
try {
|
||||
process.chdir(root);
|
||||
const code = await runCli(join(parent, "_storage"), ["init", "template", "from-cli"]);
|
||||
expect(code).toBe(0);
|
||||
expect(await pathExists(join(root, "templates", "from-cli", "package.json"))).toBe(true);
|
||||
} finally {
|
||||
process.chdir(prev);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { formatCliUsage, runCli } from "../src/cli-dispatch.js";
|
||||
import { cmdInitWorkspace } from "../src/cmd-init.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
|
||||
describe("init workspace", () => {
|
||||
let parent: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
parent = join(tmpdir(), `wf-init-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
await mkdir(parent, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(parent, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("creates expected files and directories", async () => {
|
||||
const created = await cmdInitWorkspace(parent, "my-workflows");
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = created.value.rootPath;
|
||||
expect(await pathExists(join(root, "package.json"))).toBe(true);
|
||||
expect(await pathExists(join(root, "biome.json"))).toBe(true);
|
||||
expect(await pathExists(join(root, "tsconfig.json"))).toBe(true);
|
||||
expect(await pathExists(join(root, "AGENTS.md"))).toBe(true);
|
||||
expect(await pathExists(join(root, "README.md"))).toBe(true);
|
||||
expect(await pathExists(join(root, "templates"))).toBe(true);
|
||||
expect(await pathExists(join(root, "templates", ".gitkeep"))).toBe(true);
|
||||
expect(await pathExists(join(root, "workflows", "package.json"))).toBe(true);
|
||||
|
||||
const rootPkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as {
|
||||
workspaces: string[];
|
||||
};
|
||||
expect(rootPkg.workspaces).toEqual(["templates/*", "workflows"]);
|
||||
|
||||
const wfPkg = JSON.parse(await readFile(join(root, "workflows", "package.json"), "utf8")) as {
|
||||
type: string;
|
||||
dependencies: Record<string, string>;
|
||||
};
|
||||
expect(wfPkg.type).toBe("module");
|
||||
expect(wfPkg.dependencies["@uncaged/workflow"]).toBeDefined();
|
||||
expect(wfPkg.dependencies.zod).toBeDefined();
|
||||
|
||||
const tsconfig = JSON.parse(await readFile(join(root, "tsconfig.json"), "utf8")) as {
|
||||
compilerOptions: { strict: boolean; module: string; target: string };
|
||||
};
|
||||
expect(tsconfig.compilerOptions.strict).toBe(true);
|
||||
expect(tsconfig.compilerOptions.module).toBe("ESNext");
|
||||
expect(tsconfig.compilerOptions.target).toBe("ESNext");
|
||||
});
|
||||
|
||||
test("AGENTS.md contains coding agent guide sections and terms", async () => {
|
||||
const created = await cmdInitWorkspace(parent, "my-workflows");
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const agentsPath = join(created.value.rootPath, "AGENTS.md");
|
||||
const body = await readFile(agentsPath, "utf8");
|
||||
|
||||
for (const section of [
|
||||
"项目结构",
|
||||
"核心概念",
|
||||
"开发流程",
|
||||
"编码规范",
|
||||
"Template",
|
||||
"Build",
|
||||
"常见陷阱",
|
||||
]) {
|
||||
expect(body).toContain(section);
|
||||
}
|
||||
|
||||
for (const term of [
|
||||
"RoleDefinition",
|
||||
"WorkflowDefinition",
|
||||
"Moderator",
|
||||
"AgentFn",
|
||||
"ExtractFn",
|
||||
"RoleMeta",
|
||||
]) {
|
||||
expect(body).toContain(term);
|
||||
}
|
||||
|
||||
expect(body).toMatch(/type[\s\S]*interface/i);
|
||||
expect(body).toMatch(/function[\s\S]*class/i);
|
||||
expect(body).toContain("Crockford Base32");
|
||||
expect(body).toMatch(/no[\s\S]*default export/i);
|
||||
expect(body).toMatch(/no[\s\S]*console/i);
|
||||
expect(body).toMatch(/no[\s\S]*dynamic import/i);
|
||||
|
||||
expect(body).toContain("bun run check");
|
||||
expect(body).toContain("bun test");
|
||||
expect(body).toContain("uncaged-workflow");
|
||||
expect(body).toContain("bun build");
|
||||
expect(body).toContain("CLAUDE.md");
|
||||
expect(body).toContain("docs/architecture.md");
|
||||
});
|
||||
|
||||
test("errors when directory already exists", async () => {
|
||||
const first = await cmdInitWorkspace(parent, "dup");
|
||||
expect(first.ok).toBe(true);
|
||||
|
||||
const second = await cmdInitWorkspace(parent, "dup");
|
||||
expect(second.ok).toBe(false);
|
||||
if (!second.ok) {
|
||||
expect(second.error).toContain("already exists");
|
||||
}
|
||||
});
|
||||
|
||||
test("errors on invalid workspace name", async () => {
|
||||
const slash = await cmdInitWorkspace(parent, "a/b");
|
||||
expect(slash.ok).toBe(false);
|
||||
|
||||
const dots = await cmdInitWorkspace(parent, "..");
|
||||
expect(dots.ok).toBe(false);
|
||||
|
||||
const empty = await cmdInitWorkspace(parent, "");
|
||||
expect(empty.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("usage lists init subcommands", () => {
|
||||
const u = formatCliUsage();
|
||||
expect(u).toContain("uncaged-workflow init workspace <name>");
|
||||
expect(u).toContain("uncaged-workflow init template <name>");
|
||||
});
|
||||
|
||||
test("runCli rejects unknown init subcommand", async () => {
|
||||
const code = await runCli(join(parent, "_storage"), ["init", "bogus", "name"]);
|
||||
expect(code).toBe(1);
|
||||
});
|
||||
|
||||
test.serial("runCli init workspace uses cwd", async () => {
|
||||
const prev = process.cwd();
|
||||
try {
|
||||
process.chdir(parent);
|
||||
const code = await runCli(join(parent, "_storage"), ["init", "workspace", "from-cli"]);
|
||||
expect(code).toBe(0);
|
||||
expect(await pathExists(join(parent, "from-cli", "workflows", "package.json"))).toBe(true);
|
||||
} finally {
|
||||
process.chdir(prev);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow";
|
||||
import { cmdAdd } from "../src/cmd-add.js";
|
||||
import { cmdCasPut } from "../src/cmd-cas.js";
|
||||
import { cmdKill } from "../src/cmd-kill.js";
|
||||
import { cmdPause } from "../src/cmd-pause.js";
|
||||
import { cmdPs } from "../src/cmd-ps.js";
|
||||
@@ -14,12 +12,9 @@ import { cmdResume } from "../src/cmd-resume.js";
|
||||
import { cmdRun } from "../src/cmd-run.js";
|
||||
import { cmdThreadRemove, cmdThreadShow } from "../src/cmd-thread.js";
|
||||
import { cmdThreads } from "../src/cmd-threads.js";
|
||||
import { pathExists, readTextFileIfExists } from "../src/fs-utils.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
`;
|
||||
|
||||
const threadFixtureDescriptor = `export const descriptor = {
|
||||
description: "thread-cli",
|
||||
roles: {
|
||||
@@ -34,26 +29,18 @@ const threadFixtureDescriptor = `export const descriptor = {
|
||||
`;
|
||||
|
||||
const fastBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
export const run = async function* (input) {
|
||||
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
||||
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const slowPlannerBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
export const run = async function* (input) {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
||||
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
@@ -61,38 +48,27 @@ export const run = async function* (input, options) {
|
||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
|
||||
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
export const run = async function* (input) {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
||||
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const pauseResumeBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "f");
|
||||
yield { role: "first", contentHash: h, meta: {}, refs: [h] };
|
||||
export const run = async function* (input) {
|
||||
yield { role: "first", content: "f", meta: {} };
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
h = await putContentMerkleNode(cas, "s");
|
||||
yield { role: "second", contentHash: h, meta: {}, refs: [h] };
|
||||
yield { role: "second", content: "s", meta: {} };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (_input, options) {
|
||||
export const run = async function* (input) {
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
|
||||
yield { role: "only", content: "x", meta: {} };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
@@ -162,7 +138,7 @@ describe("cli thread commands", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
return;
|
||||
@@ -199,55 +175,6 @@ describe("cli thread commands", () => {
|
||||
expect(await pathExists(dataPath)).toBe(false);
|
||||
});
|
||||
|
||||
test("thread rm runs GC and removes CAS blobs not referenced by any remaining thread", async () => {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(bundlePath, fastBundleSource, "utf8");
|
||||
|
||||
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||
expect(added.ok).toBe(true);
|
||||
if (!added.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const threadId = ran.value.threadId;
|
||||
|
||||
let threads = await cmdThreads(storageRoot, []);
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 50 && threads.ok && !threads.value.some((l) => l.includes(threadId));
|
||||
attempt++
|
||||
) {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
threads = await cmdThreads(storageRoot, []);
|
||||
}
|
||||
|
||||
const dataPath = join(storageRoot, "logs", added.value.hash, `${threadId}.data.jsonl`);
|
||||
const runningPath = join(dirname(dataPath), `${threadId}.running`);
|
||||
await waitUntilRunningFileAbsent(runningPath, 120);
|
||||
|
||||
const put = await cmdCasPut(storageRoot, threadId, "keep-after-thread-rm");
|
||||
expect(put.ok).toBe(true);
|
||||
if (!put.ok) {
|
||||
return;
|
||||
}
|
||||
const hash = put.value;
|
||||
const casBlob = join(getGlobalCasDir(storageRoot), `${hash}.txt`);
|
||||
|
||||
const removed = await cmdThreadRemove(storageRoot, threadId);
|
||||
expect(removed.ok).toBe(true);
|
||||
|
||||
const stillThere = await readTextFileIfExists(casBlob);
|
||||
expect(stillThere).toBeNull();
|
||||
});
|
||||
|
||||
test("cli entrypoint dispatches threads / ps (spawn)", () => {
|
||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||
const threads = spawnSync(process.execPath, [cliEntryPath, "threads"], {
|
||||
@@ -272,7 +199,7 @@ describe("cli thread commands", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
return;
|
||||
@@ -302,7 +229,7 @@ describe("cli thread commands", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
return;
|
||||
@@ -341,7 +268,7 @@ describe("cli thread commands", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
return;
|
||||
@@ -382,7 +309,7 @@ describe("cli thread commands", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
return;
|
||||
@@ -411,7 +338,7 @@ describe("cli thread commands", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", 5);
|
||||
const ran = await cmdRun(storageRoot, "solve-issue", "hello", false, 5);
|
||||
expect(ran.ok).toBe(true);
|
||||
if (!ran.ok) {
|
||||
return;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { printCliError, printCliLine, printCliWarn } from "./cli-output.js";
|
||||
import { cmdAdd, formatAddSuccess, parseAddArgv } from "./cmd-add.js";
|
||||
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "./cmd-cas.js";
|
||||
import { cmdFork, parseForkArgv } from "./cmd-fork.js";
|
||||
import { cmdGc } from "./cmd-gc.js";
|
||||
import { cmdHistory } from "./cmd-history.js";
|
||||
import { cmdInitTemplate, cmdInitWorkspace } from "./cmd-init.js";
|
||||
import { cmdKill } from "./cmd-kill.js";
|
||||
import { cmdList, formatListLines } from "./cmd-list.js";
|
||||
import { cmdPause } from "./cmd-pause.js";
|
||||
@@ -18,14 +15,14 @@ import { cmdThreadRemove, cmdThreadShow } from "./cmd-thread.js";
|
||||
import { cmdThreads } from "./cmd-threads.js";
|
||||
import { parseRunArgv } from "./run-argv.js";
|
||||
|
||||
export function formatCliUsage(): string {
|
||||
function usage(): string {
|
||||
return [
|
||||
"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 run <name> [--prompt <text>] [--dry-run] [--max-rounds N]",
|
||||
" uncaged-workflow ps",
|
||||
" uncaged-workflow kill <thread-id>",
|
||||
" uncaged-workflow history <name>",
|
||||
@@ -36,52 +33,13 @@ export function formatCliUsage(): string {
|
||||
" 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 sub = argv[0];
|
||||
const name = argv[1];
|
||||
if (sub === undefined || name === undefined || argv.length > 2) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: init requires workspace|template <name>`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (sub === "workspace") {
|
||||
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;
|
||||
}
|
||||
|
||||
if (sub === "template") {
|
||||
const result = await cmdInitTemplate(process.cwd(), name);
|
||||
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}`);
|
||||
printCliError(`${usage()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdAdd(storageRoot, parsed.value);
|
||||
@@ -98,7 +56,7 @@ async function dispatchAdd(storageRoot: string, argv: string[]): Promise<number>
|
||||
|
||||
async function dispatchList(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: list takes no arguments`);
|
||||
printCliError(`${usage()}\n\nerror: list takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdList(storageRoot);
|
||||
@@ -115,7 +73,7 @@ async function dispatchList(storageRoot: string, argv: string[]): Promise<number
|
||||
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>`);
|
||||
printCliError(`${usage()}\n\nerror: show requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdShow(storageRoot, name);
|
||||
@@ -130,7 +88,7 @@ async function dispatchShow(storageRoot: string, argv: string[]): Promise<number
|
||||
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>`);
|
||||
printCliError(`${usage()}\n\nerror: remove requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdRemove(storageRoot, name);
|
||||
@@ -145,7 +103,7 @@ async function dispatchRemove(storageRoot: string, argv: string[]): Promise<numb
|
||||
async function dispatchRun(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseRunArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
|
||||
printCliError(`${usage()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -153,6 +111,7 @@ async function dispatchRun(storageRoot: string, argv: string[]): Promise<number>
|
||||
storageRoot,
|
||||
parsed.value.name,
|
||||
parsed.value.prompt,
|
||||
parsed.value.dryRun,
|
||||
parsed.value.maxRounds,
|
||||
);
|
||||
if (!result.ok) {
|
||||
@@ -166,7 +125,7 @@ async function dispatchRun(storageRoot: string, argv: string[]): Promise<number>
|
||||
|
||||
async function dispatchPs(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: ps takes no arguments`);
|
||||
printCliError(`${usage()}\n\nerror: ps takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
for (const line of await cmdPs(storageRoot)) {
|
||||
@@ -178,7 +137,7 @@ async function dispatchPs(storageRoot: string, argv: string[]): Promise<number>
|
||||
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>`);
|
||||
printCliError(`${usage()}\n\nerror: kill requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdKill(storageRoot, threadId);
|
||||
@@ -193,7 +152,7 @@ async function dispatchKill(storageRoot: string, argv: string[]): Promise<number
|
||||
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>`);
|
||||
printCliError(`${usage()}\n\nerror: history requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdHistory(storageRoot, name);
|
||||
@@ -210,7 +169,7 @@ async function dispatchHistory(storageRoot: string, argv: string[]): Promise<num
|
||||
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]`);
|
||||
printCliError(`${usage()}\n\nerror: rollback requires <name> [hash]`);
|
||||
return 1;
|
||||
}
|
||||
const hashArg = argv[1];
|
||||
@@ -226,7 +185,7 @@ async function dispatchRollback(storageRoot: string, argv: string[]): Promise<nu
|
||||
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>`);
|
||||
printCliError(`${usage()}\n\nerror: pause requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdPause(storageRoot, threadId);
|
||||
@@ -241,7 +200,7 @@ async function dispatchPause(storageRoot: string, argv: string[]): Promise<numbe
|
||||
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>`);
|
||||
printCliError(`${usage()}\n\nerror: resume requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdResume(storageRoot, threadId);
|
||||
@@ -268,7 +227,7 @@ async function dispatchThreads(storageRoot: string, argv: string[]): Promise<num
|
||||
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>`);
|
||||
printCliError(`${usage()}\n\nerror: thread requires <id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdThreadShow(storageRoot, id);
|
||||
@@ -283,7 +242,7 @@ async function dispatchThread(storageRoot: string, argv: string[]): Promise<numb
|
||||
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>`);
|
||||
printCliError(`${usage()}\n\nerror: thread rm requires <id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdThreadRemove(storageRoot, id);
|
||||
@@ -303,27 +262,10 @@ async function dispatchThreadBranch(storageRoot: string, rest: string[]): Promis
|
||||
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}`);
|
||||
printCliError(`${usage()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdFork(storageRoot, parsed.value.threadId, parsed.value.fromRole);
|
||||
@@ -335,100 +277,10 @@ async function dispatchFork(storageRoot: string, argv: string[]): Promise<number
|
||||
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> = {
|
||||
add: dispatchAdd,
|
||||
init: dispatchInit,
|
||||
list: dispatchList,
|
||||
show: dispatchShow,
|
||||
remove: dispatchRemove,
|
||||
@@ -442,24 +294,22 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||
threads: dispatchThreads,
|
||||
thread: dispatchThreadBranch,
|
||||
fork: dispatchFork,
|
||||
gc: dispatchGc,
|
||||
cas: dispatchCas,
|
||||
};
|
||||
|
||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length === 0) {
|
||||
printCliError(formatCliUsage());
|
||||
printCliError(usage());
|
||||
return 1;
|
||||
}
|
||||
const command = argv[0];
|
||||
if (command === undefined) {
|
||||
printCliError(formatCliUsage());
|
||||
printCliError(usage());
|
||||
return 1;
|
||||
}
|
||||
const rest = argv.slice(1);
|
||||
const dispatch = COMMAND_TABLE[command];
|
||||
if (dispatch === undefined) {
|
||||
printCliError(`${formatCliUsage()}\n\nerror: unknown command ${command}`);
|
||||
printCliError(`${usage()}\n\nerror: unknown command ${command}`);
|
||||
return 1;
|
||||
}
|
||||
return dispatch(storageRoot, rest);
|
||||
|
||||
Executable → Regular
@@ -192,7 +192,7 @@ export async function cmdAdd(
|
||||
return validated;
|
||||
}
|
||||
|
||||
const extracted = await extractBundleExports(resolvedPath, { storageRoot });
|
||||
const extracted = await extractBundleExports(resolvedPath);
|
||||
if (!extracted.ok) {
|
||||
return extracted;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -65,9 +65,8 @@ export async function cmdFork(
|
||||
const newThreadId = generateUlid(Date.now());
|
||||
const stepsOnWire = plan.value.historicalSteps.map((s) => ({
|
||||
role: s.role,
|
||||
contentHash: s.contentHash,
|
||||
content: s.content,
|
||||
meta: s.meta,
|
||||
refs: s.refs,
|
||||
timestamp: s.timestamp,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { type GcResult, garbageCollectCas, type Result } from "@uncaged/workflow";
|
||||
|
||||
export async function cmdGc(storageRoot: string): Promise<Result<GcResult, string>> {
|
||||
return garbageCollectCas(storageRoot);
|
||||
}
|
||||
@@ -1,415 +0,0 @@
|
||||
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 CmdInitWorkspaceSuccess = {
|
||||
rootPath: string;
|
||||
};
|
||||
|
||||
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 rootPackageJson(workspaceName: string): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
name: workspaceName,
|
||||
private: true,
|
||||
type: "module",
|
||||
workspaces: ["templates/*", "workflows"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
function workflowsPackageJson(): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
name: "workflows",
|
||||
version: "0.0.0",
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow": "^0.1.0",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
function biomeJson(): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
$schema: "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
files: {
|
||||
includes: ["**", "!**/node_modules", "!**/dist"],
|
||||
},
|
||||
formatter: {
|
||||
indentWidth: 2,
|
||||
},
|
||||
linter: {
|
||||
enabled: true,
|
||||
rules: {
|
||||
recommended: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
function tsconfigJson(): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
compilerOptions: {
|
||||
strict: true,
|
||||
target: "ESNext",
|
||||
module: "ESNext",
|
||||
moduleResolution: "Bundler",
|
||||
skipLibCheck: true,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
function agentsMd(): string {
|
||||
return `# AGENTS — Workflow 工作区开发指南
|
||||
|
||||
面向在本仓库中编写 workflow 的 coding agent。引擎层术语与架构细节与 **@uncaged/workflow** 上游文档一致,编写时可对照 \`CLAUDE.md\` 与 \`docs/architecture.md\`。
|
||||
|
||||
## 1. 项目结构(workspace / template / workflow instance)
|
||||
|
||||
| 层级 | 目录 / 产物 | 职责 |
|
||||
|------|----------------|------|
|
||||
| **Workspace** | 仓库根(\`package.json\` 含 \`workspaces: ["templates/*", "workflows"]\`) | Bun monorepo:统一管理本地模板包与 workflow 实例 |
|
||||
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`、\`src/moderator.ts\`、\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **Moderator**),**不绑定**具体 Agent |
|
||||
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AgentFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
|
||||
|
||||
Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下放绑定与打包入口。
|
||||
|
||||
## 2. 核心概念
|
||||
|
||||
- **RoleMeta**:\`Record<string, Record<string, unknown>>\`,角色名 → 该角色结构化 meta 的形状约定。
|
||||
- **RoleDefinition<Meta>**:纯数据——\`description\`、\`systemPrompt\`、\`extractPrompt\`、\`schema\`(Zod v4)。不含执行逻辑。
|
||||
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **Moderator**。
|
||||
- **Moderator**:\`(ctx: ModeratorContext<M>) => (角色名) | END\`。同步、纯函数,只做路由。
|
||||
- **AgentFn**:\`(ctx: AgentContext) => Promise<string>\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`。
|
||||
- **ExtractFn**:从上下文与 prompt 解析结构化数据(引擎与 Agent 都可使用)。
|
||||
|
||||
引擎循环简述:**Moderator** → 选角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
|
||||
|
||||
## 3. 开发流程
|
||||
|
||||
1. **定义 RoleMeta**:为每个角色约定 meta 的 TypeScript 类型(与 Zod schema 对齐)。
|
||||
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`。
|
||||
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`。
|
||||
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
|
||||
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding, extract)\`(或项目约定的封装)绑定 **AgentFn** / **ExtractFn**。
|
||||
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
|
||||
|
||||
## 4. 编码规范
|
||||
|
||||
与 **CLAUDE.md** 对齐,摘要如下:
|
||||
|
||||
- **Functional-first**:优先 \`function\` + \`type\`,避免面向对象业务模型。
|
||||
- **type 而非 interface**:类型别名一律用 \`type\`,不要使用 \`interface\`。
|
||||
- **显式可空**:不要用 \`?:\`;可空字段写成 \`T | null\`。
|
||||
- **function 而非 class**:不用 class(第三方库要求或 \`Error\` 子类除外)。
|
||||
- **Crockford Base32**:日志 tag、bundle hash、thread id 等标识约定(引擎侧);工作区内自定义日志若沿用引擎 logger,tag 为 8 字符 Crockford Base32,且每个调用点唯一。
|
||||
- **Named exports only**:不要使用 **default export**;workflow bundle 须 **export const run** 与 **export const descriptor**。
|
||||
- **No console.log**:库代码用结构化 logger;CLI 用户输出可按项目 Biome 规则例外标注。
|
||||
- **No dynamic import**:业务与 bundle 内禁止 \`import()\`;例外仅限「运行时路径由用户提供的 bundle 加载器」(引擎内部)。
|
||||
|
||||
## 5. Template 复用
|
||||
|
||||
- **已发布模板**:可通过 npm 依赖 \`@uncaged/workflow-template-*\` 等包,在 workflow 实例中 import 其 **WorkflowDefinition** 再绑定 Agent。
|
||||
- **本地模板**:放在本仓库 \`templates/<name>/\`,由 workspace 协议引用(如 \`"template-foo": "workspace:*"\` 或相对路径),便于同源修改与版本控制。
|
||||
|
||||
选择模板时保持 **definition 与 agent 绑定分离**:模板只描述「做什么、顺序如何」,实例决定「谁执行、如何抽取 meta」。
|
||||
|
||||
## 6. Build and Test
|
||||
|
||||
日常命令:
|
||||
|
||||
\`\`\`sh
|
||||
bun install
|
||||
bun run check # Biome:lint + format
|
||||
bun test
|
||||
bun build # 若包内配置了 build 脚本则用于产出 dist / bundle
|
||||
uncaged-workflow add <name> <path/to/bundle.esm.js>
|
||||
\`\`\`
|
||||
|
||||
提交前至少运行 **bun run check** 与 **bun test**;registry 与本地运行流参见 README 与 CLI 文档。
|
||||
|
||||
## 7. 常见陷阱
|
||||
|
||||
- **No dynamic import**:bundle 须静态可分析;动态 \`import()\` 会破坏哈希与加载约束。
|
||||
- **No default export**:引擎只接受命名导出 \`run\` / \`descriptor\`。
|
||||
- **No console.log**:避免在可被 Biome \`noConsole\` 规则覆盖的代码路径直接使用 console。
|
||||
- **Single-file ESM bundle**:交付物是单一 \`.esm.js\`;静态 import 仅限 Node 内置(见 architecture 文档中的 Bundle Contract)。
|
||||
|
||||
---
|
||||
|
||||
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ Moderator → 绑定 → 单文件 bundle**,再对照本节规范自检。
|
||||
`;
|
||||
}
|
||||
|
||||
function readmeMd(workspaceName: string): string {
|
||||
return `# ${workspaceName}
|
||||
|
||||
Local workflow development workspace (Bun monorepo).
|
||||
|
||||
## Layout
|
||||
|
||||
- \`templates/\` — reusable workflow definition packages (roles + moderator), no agent binding
|
||||
- \`workflows/\` — workflow instances that bind templates to agents and export \`run\` + \`descriptor\`
|
||||
|
||||
## Commands
|
||||
|
||||
\`\`\`sh
|
||||
bun install
|
||||
bun run check # after you add scripts / Biome
|
||||
uncaged-workflow add <name> <bundle.esm.js>
|
||||
uncaged-workflow run <name>
|
||||
\`\`\`
|
||||
|
||||
Create this skeleton with:
|
||||
|
||||
\`\`\`sh
|
||||
uncaged-workflow init workspace ${workspaceName}
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
|
||||
export async function cmdInitWorkspace(
|
||||
parentDir: string,
|
||||
workspaceName: string,
|
||||
): Promise<Result<CmdInitWorkspaceSuccess, string>> {
|
||||
const validated = validateWorkspaceSegment(workspaceName);
|
||||
if (!validated.ok) {
|
||||
return validated;
|
||||
}
|
||||
|
||||
const rootPath = join(parentDir, workspaceName);
|
||||
if (await pathExists(rootPath)) {
|
||||
return err(`directory already exists: ${rootPath}`);
|
||||
}
|
||||
|
||||
await mkdir(rootPath, { recursive: false });
|
||||
await mkdir(join(rootPath, "templates"), { recursive: false });
|
||||
await mkdir(join(rootPath, "workflows"), { recursive: false });
|
||||
|
||||
await Promise.all([
|
||||
writeFile(join(rootPath, "package.json"), rootPackageJson(workspaceName), "utf8"),
|
||||
writeFile(join(rootPath, "biome.json"), biomeJson(), "utf8"),
|
||||
writeFile(join(rootPath, "tsconfig.json"), tsconfigJson(), "utf8"),
|
||||
writeFile(join(rootPath, "AGENTS.md"), agentsMd(), "utf8"),
|
||||
writeFile(join(rootPath, "README.md"), readmeMd(workspaceName), "utf8"),
|
||||
writeFile(join(rootPath, "templates", ".gitkeep"), "", "utf8"),
|
||||
writeFile(join(rootPath, "workflows", "package.json"), workflowsPackageJson(), "utf8"),
|
||||
]);
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -44,7 +44,6 @@ export async function cmdRollback(
|
||||
}
|
||||
|
||||
const nextRegistry = {
|
||||
config: reg.value.config,
|
||||
workflows: { ...reg.value.workflows, [name]: rolled.value },
|
||||
};
|
||||
const written = await writeWorkflowRegistry(storageRoot, nextRegistry);
|
||||
|
||||
@@ -15,6 +15,7 @@ export async function cmdRun(
|
||||
storageRoot: string,
|
||||
name: string,
|
||||
prompt: string,
|
||||
isDryRun: boolean,
|
||||
maxRounds: number,
|
||||
): Promise<Result<{ threadId: string }, string>> {
|
||||
const nameOk = validateCliWorkflowName(name);
|
||||
@@ -46,7 +47,7 @@ export async function cmdRun(
|
||||
threadId,
|
||||
workflowName: name,
|
||||
prompt,
|
||||
options: { maxRounds, depth: 0 },
|
||||
options: { isDryRun, maxRounds },
|
||||
},
|
||||
{ awaitResponseLine: false },
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { err, garbageCollectCas, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { readTextFileIfExists } from "./fs-utils.js";
|
||||
import { resolveThreadDataPath } from "./thread-scan.js";
|
||||
@@ -38,7 +38,5 @@ export async function cmdThreadRemove(
|
||||
await unlink(infoPath).catch(() => {});
|
||||
await unlink(runningPath).catch(() => {});
|
||||
|
||||
await garbageCollectCas(storageRoot);
|
||||
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
@@ -3,13 +3,20 @@ import { err, ok, type Result } from "@uncaged/workflow";
|
||||
export type ParsedRunArgv = {
|
||||
name: string;
|
||||
prompt: string;
|
||||
dryRun: boolean;
|
||||
maxRounds: number;
|
||||
};
|
||||
|
||||
type FlagOk = { kind: "prompt"; value: string } | { kind: "max-rounds"; value: number };
|
||||
type FlagOk =
|
||||
| { kind: "dry-run" }
|
||||
| { kind: "prompt"; value: string }
|
||||
| { kind: "max-rounds"; value: number };
|
||||
|
||||
function parseFlagAt(argv: string[], index: number): Result<FlagOk, string> | null {
|
||||
const flag = argv[index];
|
||||
if (flag === "--dry-run") {
|
||||
return ok({ kind: "dry-run" });
|
||||
}
|
||||
if (flag === "--prompt") {
|
||||
const value = argv[index + 1];
|
||||
if (value === undefined) {
|
||||
@@ -34,6 +41,7 @@ function parseFlagAt(argv: string[], index: number): Result<FlagOk, string> | nu
|
||||
export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
|
||||
let name: string | undefined;
|
||||
let prompt = "";
|
||||
let dryRun = false;
|
||||
let maxRounds = 5;
|
||||
|
||||
let i = 0;
|
||||
@@ -54,6 +62,11 @@ export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
|
||||
}
|
||||
|
||||
const flag = parsed.value;
|
||||
if (flag.kind === "dry-run") {
|
||||
dryRun = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (flag.kind === "prompt") {
|
||||
prompt = flag.value;
|
||||
i += 2;
|
||||
@@ -67,5 +80,5 @@ export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
|
||||
return err("run requires <name>");
|
||||
}
|
||||
|
||||
return ok({ name, prompt, maxRounds });
|
||||
return ok({ name, prompt, dryRun, maxRounds });
|
||||
}
|
||||
|
||||
@@ -1,41 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ExtractContext, ExtractFn } from "@uncaged/workflow";
|
||||
import type * as z from "zod/v4";
|
||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||
|
||||
const testExtract: ExtractFn = async <T extends Record<string, unknown>>(
|
||||
_schema: z.ZodType<T>,
|
||||
_prompt: string,
|
||||
_ctx: ExtractContext,
|
||||
): Promise<T> => ({ workspace: "/tmp" }) as unknown as T;
|
||||
|
||||
describe("validateCursorAgentConfig", () => {
|
||||
test("accepts valid config", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
workdir: "/tmp",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
extract: testExtract,
|
||||
timeout: null,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects non-function extract", () => {
|
||||
test("rejects empty workdir", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
workdir: " ",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
extract: null as unknown as ExtractFn,
|
||||
timeout: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("extract");
|
||||
expect(r.error).toContain("workdir");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects negative timeout", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
workdir: "/tmp",
|
||||
model: null,
|
||||
timeout: -1,
|
||||
extract: testExtract,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
@@ -44,9 +36,9 @@ describe("validateCursorAgentConfig", () => {
|
||||
describe("createCursorAgent", () => {
|
||||
test("returns an AgentFn", () => {
|
||||
const agent = createCursorAgent({
|
||||
workdir: "/tmp",
|
||||
model: null,
|
||||
timeout: 0,
|
||||
extract: testExtract,
|
||||
timeout: null,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
@@ -54,9 +46,9 @@ describe("createCursorAgent", () => {
|
||||
test("throws on invalid config at construction", () => {
|
||||
expect(() =>
|
||||
createCursorAgent({
|
||||
workdir: "",
|
||||
model: null,
|
||||
timeout: -1,
|
||||
extract: testExtract,
|
||||
timeout: null,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-util-agent": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
"@uncaged/workflow-util-agent": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AgentFn, ExtractContext } from "@uncaged/workflow";
|
||||
import type { AgentFn } from "@uncaged/workflow";
|
||||
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import type { CursorAgentConfig } from "./types.js";
|
||||
import { validateCursorAgentConfig } from "./validate-config.js";
|
||||
@@ -9,12 +8,6 @@ export { buildAgentPrompt } from "@uncaged/workflow-util-agent";
|
||||
export type { CursorAgentConfig } from "./types.js";
|
||||
export { validateCursorAgentConfig } from "./validate-config.js";
|
||||
|
||||
const cursorWorkspaceSchema = z.object({
|
||||
workspace: z
|
||||
.string()
|
||||
.describe("Absolute path to the project/repository directory the agent should work in"),
|
||||
});
|
||||
|
||||
function throwCursorSpawnError(error: SpawnCliError): never {
|
||||
if (error.kind === "non_zero_exit") {
|
||||
throw new Error(
|
||||
@@ -34,7 +27,7 @@ function resolveCursorModel(model: string | null): string {
|
||||
return model === null ? "auto" : model;
|
||||
}
|
||||
|
||||
/** Runs `cursor-agent` with workspace from {@link CursorAgentConfig.extract} and prompt from context. */
|
||||
/** Runs `cursor-agent` in {@link CursorAgentConfig.workdir} with a prompt built from context + system prompt. */
|
||||
export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
const validated = validateCursorAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
@@ -42,33 +35,22 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
}
|
||||
|
||||
const modelFlag = resolveCursorModel(config.model);
|
||||
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
||||
const timeoutMs = config.timeout;
|
||||
|
||||
return async (ctx) => {
|
||||
const extractCtx: ExtractContext = {
|
||||
...ctx,
|
||||
agentContent: "",
|
||||
};
|
||||
const { workspace } = await config.extract(
|
||||
cursorWorkspaceSchema,
|
||||
"From the thread context, determine the absolute filesystem path where the project/repository is located.",
|
||||
extractCtx,
|
||||
);
|
||||
const fullPrompt = await buildAgentPrompt(ctx);
|
||||
return async (ctx, systemPrompt) => {
|
||||
const fullPrompt = buildAgentPrompt(systemPrompt, ctx);
|
||||
const args = [
|
||||
"-p",
|
||||
fullPrompt,
|
||||
"--model",
|
||||
modelFlag,
|
||||
"--workspace",
|
||||
workspace,
|
||||
"--output-format",
|
||||
"text",
|
||||
"--trust",
|
||||
"--force",
|
||||
];
|
||||
const run = await spawnCli("cursor-agent", args, {
|
||||
cwd: workspace,
|
||||
cwd: config.workdir,
|
||||
timeoutMs,
|
||||
});
|
||||
if (!run.ok) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { ExtractFn } from "@uncaged/workflow";
|
||||
|
||||
export type CursorAgentConfig = {
|
||||
workdir: string;
|
||||
model: string | null;
|
||||
timeout: number;
|
||||
extract: ExtractFn;
|
||||
timeout: number | null;
|
||||
};
|
||||
|
||||
@@ -3,11 +3,11 @@ import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import type { CursorAgentConfig } from "./types.js";
|
||||
|
||||
export function validateCursorAgentConfig(config: CursorAgentConfig): Result<void, string> {
|
||||
if (typeof config.extract !== "function") {
|
||||
return err("extract must be a function");
|
||||
if (config.workdir.trim() === "") {
|
||||
return err("workdir must be a non-empty string");
|
||||
}
|
||||
if (config.timeout < 0) {
|
||||
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
|
||||
if (config.timeout !== null && config.timeout < 0) {
|
||||
return err("timeout must be null or a non-negative number (milliseconds)");
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ export function createHermesAgent(config: HermesAgentConfig): AgentFn {
|
||||
|
||||
const timeoutMs = config.timeout;
|
||||
|
||||
return async (ctx) => {
|
||||
const fullPrompt = await buildAgentPrompt(ctx);
|
||||
return async (ctx, systemPrompt) => {
|
||||
const fullPrompt = buildAgentPrompt(systemPrompt, ctx);
|
||||
const args = [
|
||||
"chat",
|
||||
"-q",
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-llm",
|
||||
"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:*"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export {
|
||||
chatCompletionText,
|
||||
createLlmAdapter,
|
||||
type LlmChatError,
|
||||
type LlmMessage,
|
||||
} from "./create-llm-adapter.js";
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const coderMetaSchema = z.object({
|
||||
completedPhase: z.string(),
|
||||
filesChanged: z.array(z.string()),
|
||||
summary: z.string(),
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
## Finding the current thread ID
|
||||
|
||||
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
|
||||
|
||||
Each planner phase is identified by a content-hash and a title. To read a phase's full details (name, description, acceptance criteria), run:
|
||||
|
||||
uncaged-workflow cas get <THREAD_ID> <HASH>
|
||||
|
||||
Replace \`<THREAD_ID>\` with the actual thread ID and \`<HASH>\` with the phase hash from the plan.
|
||||
|
||||
## Completing a phase
|
||||
|
||||
Report which phase you completed using the phase **hash** (not the title). If you legitimately finish every remaining phase in this single turn, set completedPhase to the **last** phase hash in the plan (the workflow treats that as full completion). List the files you changed and summarize what you did.`;
|
||||
|
||||
export const coderRole: RoleDefinition<CoderMeta> = {
|
||||
description:
|
||||
"Implements the next incomplete planner phase and reports structured completion metadata.",
|
||||
systemPrompt: CODER_SYSTEM,
|
||||
extractPrompt:
|
||||
"Extract completedPhase: the planner phase hash finished this round (exact hash string from the plan). If multiple phases were finished in one round, use the last finished phase hash. Extract filesChanged and a summary of the work.",
|
||||
schema: coderMetaSchema,
|
||||
extractRefs: (meta) => [meta.completedPhase],
|
||||
extractMode: "single",
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { type CoderMeta, coderMetaSchema, coderRole } from "./coder.js";
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }]
|
||||
}
|
||||
@@ -1,19 +1,110 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, spyOn, test } from "bun:test";
|
||||
import { execFile } from "node:child_process";
|
||||
import { appendFile, mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { committerMetaSchema, committerRole } from "../src/committer.js";
|
||||
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
|
||||
import { START } from "@uncaged/workflow";
|
||||
import * as utilRole from "@uncaged/workflow-util-role";
|
||||
|
||||
describe("committerRole", () => {
|
||||
test("committed sample validates against schema", () => {
|
||||
const parsed = committerMetaSchema.safeParse({
|
||||
status: "committed" as const,
|
||||
branch: "feat/example",
|
||||
commitSha: "abc1234",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
import { createCommitterRole } from "../src/committer.js";
|
||||
import { gitExec } from "../src/git-exec.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function git(repo: string, args: string[]): Promise<void> {
|
||||
await gitExec(repo, args);
|
||||
}
|
||||
|
||||
async function setupRepoWithRemote(): Promise<{ repo: string }> {
|
||||
const base = await mkdtemp(join(tmpdir(), "wf-committer-"));
|
||||
const bare = join(base, "origin.git");
|
||||
const repo = join(base, "work");
|
||||
await mkdir(repo, { recursive: true });
|
||||
await mkdir(bare, { recursive: true });
|
||||
await execFileAsync("git", ["init"], { cwd: repo, encoding: "utf8" });
|
||||
await git(repo, ["config", "user.email", "t@t"]);
|
||||
await git(repo, ["config", "user.name", "t"]);
|
||||
await writeFile(join(repo, "README.md"), "# hi\n", "utf8");
|
||||
await git(repo, ["add", "README.md"]);
|
||||
await git(repo, ["commit", "-m", "init"]);
|
||||
await execFileAsync("git", ["init", "--bare"], { cwd: bare, encoding: "utf8" });
|
||||
await git(repo, ["remote", "add", "origin", bare]);
|
||||
await git(repo, ["push", "-u", "origin", "HEAD"]);
|
||||
return { repo };
|
||||
}
|
||||
|
||||
function makeCtx(): ThreadContext {
|
||||
return {
|
||||
start: {
|
||||
role: START,
|
||||
content: "do thing",
|
||||
meta: { maxRounds: 10 },
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
steps: [],
|
||||
};
|
||||
}
|
||||
|
||||
const provider = { baseUrl: "https://example.com/v1", apiKey: "k", model: "m" };
|
||||
|
||||
describe("createCommitterRole", () => {
|
||||
test("returns committed false when working tree clean", async () => {
|
||||
const { repo } = await setupRepoWithRemote();
|
||||
const agent: AgentFn = async () => {
|
||||
throw new Error("agent should not run");
|
||||
};
|
||||
const role = createCommitterRole(
|
||||
agent,
|
||||
{ provider, dryRun: null, dryRunMeta: { branch: "dry-run", message: "chore: dry run" } },
|
||||
{ cwd: repo, remote: "origin", threadId: null },
|
||||
);
|
||||
const out = await role(makeCtx());
|
||||
expect(out.meta.committed).toBe(false);
|
||||
});
|
||||
|
||||
test("exposes generic committer system prompt", () => {
|
||||
expect(committerRole.systemPrompt).toContain("git committer");
|
||||
expect(committerRole.systemPrompt).not.toContain("project is at");
|
||||
test("dry-run skips pipeline", async () => {
|
||||
const agent: AgentFn = async () => {
|
||||
throw new Error("agent should not run");
|
||||
};
|
||||
const role = createCommitterRole(agent, {
|
||||
provider,
|
||||
dryRun: true,
|
||||
dryRunMeta: { branch: "dry-run", message: "chore: dry run" },
|
||||
});
|
||||
const out = await role(makeCtx());
|
||||
expect(out.content).toBe("[dry-run] committer skipped");
|
||||
expect(out.meta).toEqual({ committed: true });
|
||||
});
|
||||
|
||||
test("commits and pushes when changes exist", async () => {
|
||||
const { repo } = await setupRepoWithRemote();
|
||||
await appendFile(join(repo, "README.md"), "\nmore\n", "utf8");
|
||||
|
||||
const spy = spyOn(utilRole, "extractMetaOrThrow").mockResolvedValue({
|
||||
branch: "feat/test-commit",
|
||||
message: "feat: add more",
|
||||
});
|
||||
|
||||
const agent: AgentFn = async () => "plan text";
|
||||
const role = createCommitterRole(
|
||||
agent,
|
||||
{ provider, dryRun: null, dryRunMeta: { branch: "dry-run", message: "chore: dry run" } },
|
||||
{ cwd: repo, remote: "origin", threadId: null },
|
||||
);
|
||||
|
||||
const out = await role(makeCtx());
|
||||
expect(out.meta.committed).toBe(true);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
|
||||
const branches = await gitExec(repo, ["branch", "--list", "feat/test-commit"]);
|
||||
expect(branches).toContain("feat/test-commit");
|
||||
|
||||
const remoteRefs = await gitExec(repo, ["ls-remote", "--heads", "origin", "feat/test-commit"]);
|
||||
expect(remoteRefs.trim().length).toBeGreaterThan(0);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-util-role": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,153 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import type { AgentFn, Role, RoleResult, ThreadContext } from "@uncaged/workflow";
|
||||
import {
|
||||
decorateRole,
|
||||
extractMetaOrThrow,
|
||||
type LlmProvider,
|
||||
onFail,
|
||||
withDryRun,
|
||||
} from "@uncaged/workflow-util-role";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const committerMetaSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("committed"),
|
||||
branch: z.string(),
|
||||
commitSha: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("recoverable"),
|
||||
error: z.string(),
|
||||
logRef: z.string().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("unrecoverable"),
|
||||
error: z.string(),
|
||||
logRef: z.string().nullable(),
|
||||
}),
|
||||
]);
|
||||
import { gitExec } from "./git-exec.js";
|
||||
|
||||
export const committerMetaSchema = z.object({
|
||||
committed: z
|
||||
.boolean()
|
||||
.describe("true if branch created, changes committed, and pushed successfully"),
|
||||
});
|
||||
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
|
||||
|
||||
const COMMITTER_SYSTEM = `You are the git committer. Create a branch, commit the changes, and push.
|
||||
Report the branch name and commit SHA. On failure, classify as recoverable or unrecoverable.
|
||||
Do not attempt to fix failures yourself.`;
|
||||
const committerPlanSchema = z.object({
|
||||
branch: z.string().describe("Feature branch name, e.g. feat/slug or fix/slug"),
|
||||
message: z.string().describe("Single-line conventional commit subject"),
|
||||
});
|
||||
|
||||
export const committerRole: RoleDefinition<CommitterMeta> = {
|
||||
description: "Creates branch, commits, and pushes when review passes.",
|
||||
systemPrompt: COMMITTER_SYSTEM,
|
||||
extractPrompt:
|
||||
"Extract the commit result: committed (with branch and SHA), recoverable failure, or unrecoverable failure. Include error details and log references if applicable.",
|
||||
schema: committerMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
export type CommitterPlanMeta = z.infer<typeof committerPlanSchema>;
|
||||
|
||||
export type CommitterGitConfig = {
|
||||
cwd: string;
|
||||
remote: string;
|
||||
/** When non-null, prompts mention `uncaged-workflow thread <id>` for extra context. */
|
||||
threadId: string | null;
|
||||
};
|
||||
|
||||
export const DEFAULT_COMMITTER_GIT_CONFIG: CommitterGitConfig = {
|
||||
cwd: ".",
|
||||
remote: "origin",
|
||||
threadId: null,
|
||||
};
|
||||
|
||||
function resolveExtractDryRun(extractDryRun: boolean | null): boolean {
|
||||
return extractDryRun === true;
|
||||
}
|
||||
|
||||
function summarizeThreadContext(ctx: ThreadContext): string {
|
||||
const lines: string[] = [`Initial prompt:\n${ctx.start.content}`];
|
||||
for (const step of ctx.steps) {
|
||||
const snippet = step.content.length > 800 ? `${step.content.slice(0, 800)}…` : step.content;
|
||||
lines.push(`\n### ${step.role}\n${snippet}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function sanitizeBranch(branch: string): string {
|
||||
const t = branch.trim();
|
||||
if (
|
||||
t === "" ||
|
||||
t.includes("..") ||
|
||||
t.includes(" ") ||
|
||||
t.startsWith("-") ||
|
||||
t.includes("\n") ||
|
||||
t.includes("\t")
|
||||
) {
|
||||
throw new Error(`invalid branch name: ${branch}`);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
function sanitizeCommitMessage(message: string): string {
|
||||
const line = message.trim().split(/\r?\n/)[0] ?? "";
|
||||
if (line === "") {
|
||||
throw new Error("commit message is empty");
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function committerPlanPrompt(ctx: ThreadContext, gitConfig: CommitterGitConfig): string {
|
||||
const threadLine =
|
||||
gitConfig.threadId !== null
|
||||
? `Optional CLI context: run \`uncaged-workflow thread ${gitConfig.threadId}\` if available.\n`
|
||||
: "";
|
||||
|
||||
return `You plan a git branch and a single-line conventional commit message for the following workflow thread.
|
||||
|
||||
${threadLine}
|
||||
## Thread context
|
||||
|
||||
${summarizeThreadContext(ctx)}
|
||||
|
||||
## Your task
|
||||
|
||||
Infer a good branch name (\`feat/<slug>\` or \`fix/<slug>\`) and a conventional commit **subject** (one line, no body).
|
||||
|
||||
Reply with enough detail that a maintainer understands the change; structured extraction will read \`branch\` and \`message\` from your answer.`;
|
||||
}
|
||||
|
||||
async function runCommitterPipeline(
|
||||
ctx: ThreadContext,
|
||||
agent: AgentFn,
|
||||
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: CommitterPlanMeta },
|
||||
gitConfig: CommitterGitConfig,
|
||||
): Promise<RoleResult<CommitterMeta>> {
|
||||
const cwd = gitConfig.cwd;
|
||||
const porcelain = await gitExec(cwd, ["status", "--porcelain"]);
|
||||
if (porcelain.trim() === "") {
|
||||
return {
|
||||
content: "Working tree clean; nothing to commit.",
|
||||
meta: { committed: false },
|
||||
};
|
||||
}
|
||||
|
||||
const prompt = committerPlanPrompt(ctx, gitConfig);
|
||||
const raw = await agent(ctx, prompt);
|
||||
const plan = await extractMetaOrThrow("committer-plan", raw, committerPlanSchema, {
|
||||
provider: extract.provider,
|
||||
dryRun: resolveExtractDryRun(extract.dryRun),
|
||||
dryRunMeta: extract.dryRunMeta,
|
||||
});
|
||||
|
||||
const branch = sanitizeBranch(plan.branch);
|
||||
const message = sanitizeCommitMessage(plan.message);
|
||||
|
||||
await gitExec(cwd, ["checkout", "-b", branch]);
|
||||
await gitExec(cwd, ["add", "-A"]);
|
||||
await gitExec(cwd, ["commit", "-m", message]);
|
||||
await gitExec(cwd, ["push", "-u", gitConfig.remote, branch]);
|
||||
|
||||
return {
|
||||
content: raw,
|
||||
meta: { committed: true },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Git committer role: LLM proposes branch + message; this package runs git via `child_process`.
|
||||
* Decorators match nerve semantics: dry-run skips work with `committed: true`; failures yield `committed: false`.
|
||||
*/
|
||||
export function createCommitterRole(
|
||||
adapter: AgentFn,
|
||||
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: CommitterPlanMeta },
|
||||
gitConfig: CommitterGitConfig = DEFAULT_COMMITTER_GIT_CONFIG,
|
||||
): Role<CommitterMeta> {
|
||||
const inner: Role<CommitterMeta> = async (ctx) =>
|
||||
runCommitterPipeline(ctx, adapter, extract, gitConfig);
|
||||
|
||||
return decorateRole(inner, [
|
||||
withDryRun<CommitterMeta>({
|
||||
label: "committer",
|
||||
meta: { committed: true },
|
||||
dryRun: resolveExtractDryRun(extract.dryRun),
|
||||
}),
|
||||
onFail<CommitterMeta>({ label: "committer", meta: { committed: false } }),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Runs `git` with args in `cwd`; throws if git exits non-zero. */
|
||||
export async function gitExec(cwd: string, args: readonly string[]): Promise<string> {
|
||||
try {
|
||||
const r = await execFileAsync("git", [...args], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return r.stdout;
|
||||
} catch (e) {
|
||||
const stderr =
|
||||
typeof e === "object" &&
|
||||
e !== null &&
|
||||
"stderr" in e &&
|
||||
typeof (e as { stderr: unknown }).stderr === "string"
|
||||
? (e as { stderr: string }).stderr
|
||||
: "";
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new Error(`git ${args.join(" ")} failed: ${msg}${stderr ? ` (${stderr.trim()})` : ""}`);
|
||||
}
|
||||
}
|
||||
@@ -1 +1,9 @@
|
||||
export { type CommitterMeta, committerMetaSchema, committerRole } from "./committer.js";
|
||||
export {
|
||||
type CommitterGitConfig,
|
||||
type CommitterMeta,
|
||||
type CommitterPlanMeta,
|
||||
committerMetaSchema,
|
||||
createCommitterRole,
|
||||
DEFAULT_COMMITTER_GIT_CONFIG,
|
||||
} from "./committer.js";
|
||||
export { gitExec } from "./git-exec.js";
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
"composite": true,
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }]
|
||||
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-role" }]
|
||||
}
|
||||
|
||||
+4
-14
@@ -1,14 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, START, type ThreadContext } from "@uncaged/workflow";
|
||||
import { START, type ThreadContext } from "@uncaged/workflow";
|
||||
|
||||
import { createLlmAdapter } from "../src/create-llm-adapter.js";
|
||||
|
||||
const casDir = mkdtempSync(join(tmpdir(), "wf-llm-adapter-cas-"));
|
||||
const testCas = createCasStore(casDir);
|
||||
|
||||
function makeCtx(userContent: string): ThreadContext {
|
||||
return {
|
||||
start: {
|
||||
@@ -17,11 +11,7 @@ function makeCtx(userContent: string): ThreadContext {
|
||||
meta: { maxRounds: 10 },
|
||||
timestamp: 1,
|
||||
},
|
||||
depth: 0,
|
||||
steps: [],
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "planner", systemPrompt: "system instructions" },
|
||||
cas: testCas,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,7 +29,7 @@ describe("createLlmAdapter", () => {
|
||||
|
||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||
const adapter = createLlmAdapter(provider);
|
||||
const out = await adapter(makeCtx("trigger text"));
|
||||
const out = await adapter(makeCtx("trigger text"), "system instructions");
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
@@ -58,7 +48,7 @@ describe("createLlmAdapter", () => {
|
||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||
const adapter = createLlmAdapter(provider);
|
||||
|
||||
await expect(adapter(makeCtx("hi"))).rejects.toThrow("llm:");
|
||||
await expect(adapter(makeCtx("hi"), "sys")).rejects.toThrow("llm:");
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
@@ -68,7 +58,7 @@ describe("createLlmAdapter", () => {
|
||||
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
|
||||
const adapter = createLlmAdapter(provider);
|
||||
|
||||
await expect(adapter(makeCtx("hi"))).rejects.toThrow();
|
||||
await expect(adapter(makeCtx("hi"), "sys")).rejects.toThrow();
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
|
||||
import { START } from "@uncaged/workflow";
|
||||
import * as extractMetaModule from "@uncaged/workflow-util-role";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createRole } from "../src/create-role.js";
|
||||
|
||||
const provider = {
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: "k",
|
||||
model: "m",
|
||||
};
|
||||
|
||||
function toolCallResponse(argsJson: string): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: "extract",
|
||||
arguments: argsJson,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
function makeCtx(): ThreadContext {
|
||||
return {
|
||||
start: {
|
||||
role: START,
|
||||
content: "",
|
||||
meta: { maxRounds: 10 },
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
steps: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("createRole", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
test("runs AgentFn then structured extract", async () => {
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(toolCallResponse(JSON.stringify({ n: 3 })))) as unknown as typeof fetch;
|
||||
|
||||
const schema = z.object({ n: z.number() });
|
||||
const agent: AgentFn = async (_ctx, prompt) => prompt;
|
||||
const role = createRole({
|
||||
name: "test",
|
||||
schema,
|
||||
systemPrompt: "hello",
|
||||
agent,
|
||||
extract: { provider, dryRun: null, dryRunMeta: { n: 0 } },
|
||||
});
|
||||
|
||||
const out = await role(makeCtx());
|
||||
expect(out.content).toBe("hello");
|
||||
expect(out.meta).toEqual({ n: 3 });
|
||||
});
|
||||
|
||||
test("passes ThreadContext to AgentFn", async () => {
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(toolCallResponse(JSON.stringify({ n: 0 })))) as unknown as typeof fetch;
|
||||
|
||||
const seen: ThreadContext[] = [];
|
||||
const agent: AgentFn = async (ctx, _prompt) => {
|
||||
seen.push(ctx);
|
||||
return "x";
|
||||
};
|
||||
const role = createRole({
|
||||
name: "test",
|
||||
schema: z.object({ n: z.number() }),
|
||||
systemPrompt: "p",
|
||||
agent,
|
||||
extract: { provider, dryRun: null, dryRunMeta: { n: 0 } },
|
||||
});
|
||||
await role(makeCtx());
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].steps).toEqual([]);
|
||||
});
|
||||
|
||||
test("resolves dynamic systemPrompt functions before AgentFn", async () => {
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(toolCallResponse(JSON.stringify({ n: 99 })))) as unknown as typeof fetch;
|
||||
|
||||
const schema = z.object({ n: z.number() });
|
||||
const agent: AgentFn = async (_ctx, prompt) => prompt;
|
||||
const role = createRole({
|
||||
name: "test",
|
||||
schema,
|
||||
systemPrompt: async (ctx) => `rounds=${ctx.steps.length}`,
|
||||
agent,
|
||||
extract: { provider, dryRun: null, dryRunMeta: { n: 0 } },
|
||||
});
|
||||
|
||||
const ctx = makeCtx();
|
||||
const out = await role(ctx);
|
||||
expect(out.content).toBe("rounds=0");
|
||||
expect(out.meta).toEqual({ n: 99 });
|
||||
});
|
||||
|
||||
test("extract dryRun null runs live extract path", async () => {
|
||||
const spy = spyOn(extractMetaModule, "extractMetaOrThrow").mockResolvedValue({ n: 0 });
|
||||
|
||||
const agent: AgentFn = async () => "raw";
|
||||
const role = createRole({
|
||||
name: "r1",
|
||||
schema: z.object({ n: z.number() }),
|
||||
systemPrompt: "p",
|
||||
agent,
|
||||
extract: { provider, dryRun: null, dryRunMeta: { n: 0 } },
|
||||
});
|
||||
await role(makeCtx());
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
"r1",
|
||||
"raw",
|
||||
expect.anything(),
|
||||
expect.objectContaining({ provider, dryRun: false, dryRunMeta: { n: 0 } }),
|
||||
);
|
||||
});
|
||||
|
||||
test("extract.dryRun true uses structured extract dry-run", async () => {
|
||||
const spy = spyOn(extractMetaModule, "extractMetaOrThrow").mockResolvedValue({ n: 0 });
|
||||
|
||||
const agent: AgentFn = async () => "raw";
|
||||
const role = createRole({
|
||||
name: "r2",
|
||||
schema: z.object({ n: z.number() }),
|
||||
systemPrompt: "p",
|
||||
agent,
|
||||
extract: { provider, dryRun: true, dryRunMeta: { n: 0 } },
|
||||
});
|
||||
await role(makeCtx());
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
"r2",
|
||||
"raw",
|
||||
expect.anything(),
|
||||
expect.objectContaining({ dryRun: true, dryRunMeta: { n: 0 } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,19 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-role-coder",
|
||||
"name": "@uncaged/workflow-role-llm",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "echo 'TODO'",
|
||||
"test": "echo no tests"
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-util-role": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
+5
-13
@@ -1,14 +1,6 @@
|
||||
import {
|
||||
type AgentContext,
|
||||
type AgentFn,
|
||||
err,
|
||||
type LlmProvider,
|
||||
ok,
|
||||
type Result,
|
||||
} from "@uncaged/workflow";
|
||||
import { type AgentFn, err, ok, type Result, type ThreadContext } from "@uncaged/workflow";
|
||||
|
||||
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
|
||||
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
||||
import type { LlmMessage, LlmProvider } from "@uncaged/workflow-util-role";
|
||||
|
||||
export type LlmChatError =
|
||||
| { kind: "http_error"; status: number; body: string }
|
||||
@@ -97,13 +89,13 @@ export async function chatCompletionText(options: {
|
||||
return parseAssistantText(res.value);
|
||||
}
|
||||
|
||||
/** Single-turn chat adapter: system prompt comes from {@link AgentContext.currentRole}. */
|
||||
/** Single-turn chat adapter: system comes from `createRole` prompt; user is the thread start frame. */
|
||||
export function createLlmAdapter(provider: LlmProvider): AgentFn {
|
||||
return async (ctx: AgentContext) => {
|
||||
return async (ctx: ThreadContext, systemPrompt: string) => {
|
||||
const result = await chatCompletionText({
|
||||
provider,
|
||||
messages: [
|
||||
{ role: "system", content: ctx.currentRole.systemPrompt },
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: ctx.start.content },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
|
||||
import { extractMetaOrThrow, type LlmProvider } from "@uncaged/workflow-util-role";
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
export type CreateRoleArgs<M extends Record<string, unknown>> = {
|
||||
name: string;
|
||||
schema: z.ZodType<M>;
|
||||
systemPrompt: string | ((ctx: ThreadContext) => Promise<string>);
|
||||
agent: AgentFn;
|
||||
extract: {
|
||||
provider: LlmProvider;
|
||||
/** When `true`, structured extract returns `dryRunMeta`. When `null`, live API extract. */
|
||||
dryRun: boolean | null;
|
||||
dryRunMeta: M;
|
||||
};
|
||||
};
|
||||
|
||||
function resolveExtractDryRun(extractDryRun: boolean | null): boolean {
|
||||
return extractDryRun === true;
|
||||
}
|
||||
|
||||
/** Builds a {@link Role} from an {@link AgentFn}, system prompt, Zod meta schema, and extract wiring. */
|
||||
export function createRole<M extends Record<string, unknown>>(args: CreateRoleArgs<M>): Role<M> {
|
||||
return async (ctx: ThreadContext) => {
|
||||
const promptText =
|
||||
typeof args.systemPrompt === "string" ? args.systemPrompt : await args.systemPrompt(ctx);
|
||||
const raw = await args.agent(ctx, promptText);
|
||||
const meta = await extractMetaOrThrow(args.name, raw, args.schema, {
|
||||
provider: args.extract.provider,
|
||||
dryRun: resolveExtractDryRun(args.extract.dryRun),
|
||||
dryRunMeta: args.extract.dryRunMeta,
|
||||
});
|
||||
return { content: raw, meta };
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export {
|
||||
buildDescriptorFromRoles,
|
||||
decorateRole,
|
||||
extractMetaOrThrow,
|
||||
type LlmError,
|
||||
type LlmExtractArgs,
|
||||
type LlmMessage,
|
||||
type LlmProvider,
|
||||
llmErrorToCause,
|
||||
llmExtract,
|
||||
llmExtractWithRetry,
|
||||
type MetaExtractConfig,
|
||||
type OnFailOptions,
|
||||
onFail,
|
||||
type RoleDecorator,
|
||||
type RoleDescriptorInput,
|
||||
type WithDryRunOptions,
|
||||
withDryRun,
|
||||
} from "@uncaged/workflow-util-role";
|
||||
export { chatCompletionText, createLlmAdapter, type LlmChatError } from "./create-llm-adapter.js";
|
||||
export { type CreateRoleArgs, createRole } from "./create-role.js";
|
||||
+1
-1
@@ -6,5 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }]
|
||||
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-role" }]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export {
|
||||
type PlannerMeta,
|
||||
phaseSchema,
|
||||
plannerMetaSchema,
|
||||
plannerRole,
|
||||
} from "./planner.js";
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const phaseSchema = z.object({
|
||||
hash: z.string(),
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
export const plannerMetaSchema = z.object({
|
||||
phases: z.array(phaseSchema),
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
## Finding the current thread ID
|
||||
|
||||
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
|
||||
|
||||
For each phase you MUST store its full detail text in CAS using this exact CLI command:
|
||||
|
||||
uncaged-workflow cas put <THREAD_ID> '# <name>
|
||||
|
||||
Description: <description>
|
||||
|
||||
Acceptance: <acceptance>'
|
||||
|
||||
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.
|
||||
|
||||
**Do NOT store phase details in any other way** (no temp files, no invented paths). The CLI command is the only supported storage mechanism.
|
||||
|
||||
## Output format
|
||||
|
||||
After storing all phases via the CLI, output compact JSON only:
|
||||
{ "phases": [{ "hash": "<hash-from-cas-put>", "title": "<one-line-summary>" }] }
|
||||
|
||||
Order phases so earlier steps unblock later ones. Cover root cause, edge cases, and verification across the phases.`;
|
||||
|
||||
export const plannerRole: RoleDefinition<PlannerMeta> = {
|
||||
description: "Breaks the task into sequential phases for the coder.",
|
||||
systemPrompt: PLANNER_SYSTEM,
|
||||
extractPrompt:
|
||||
"Extract the implementation phases from the agent's output. Each phase has a hash (the CAS content-hash returned by the cas put command) and a title (one-line summary).",
|
||||
schema: plannerMetaSchema,
|
||||
extractRefs: (meta) => meta.phases.map((p) => p.hash),
|
||||
extractMode: "single",
|
||||
};
|
||||
@@ -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,5 +0,0 @@
|
||||
export {
|
||||
type PreparerMeta,
|
||||
preparerMetaSchema,
|
||||
preparerRole,
|
||||
} from "./preparer.js";
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
const toolchainSchema = z.object({
|
||||
packageManager: z.union([z.string(), z.null()]),
|
||||
testCommand: z.union([z.string(), z.null()]),
|
||||
lintCommand: z.union([z.string(), z.null()]),
|
||||
buildCommand: z.union([z.string(), z.null()]),
|
||||
});
|
||||
|
||||
export const preparerMetaSchema = z.object({
|
||||
repoPath: z.string(),
|
||||
defaultBranch: z.string(),
|
||||
conventions: z.union([z.string(), z.null()]),
|
||||
toolchain: toolchainSchema,
|
||||
});
|
||||
|
||||
export type PreparerMeta = z.infer<typeof preparerMetaSchema>;
|
||||
|
||||
const PREPARER_SYSTEM = `You are a **preparer** for a software task. Your job is to locate (or clone) the target repository locally, ensure it is up to date, and gather project context before work begins.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
1. Parse the issue/task prompt to identify the target repository (URL, org/repo, or name).
|
||||
2. Search for an existing local clone in these locations (in order):
|
||||
- ~/Code/<repo-name>/
|
||||
- ~/repos/<repo-name>/
|
||||
- ~/Code/<org>/<repo-name>/
|
||||
- ~/repos/<org>/<repo-name>/
|
||||
3. If not found locally, \`git clone\` it into ~/repos/<repo-name>/.
|
||||
4. \`git checkout main && git pull\` (or the default branch) to ensure latest.
|
||||
5. Read project conventions: \`CLAUDE.md\`, \`CONTRIBUTING.md\`, \`.cursor/rules/*.mdc\`, \`CONVENTIONS.md\`.
|
||||
6. Detect toolchain: package manager, test runner, linter, build system.
|
||||
|
||||
## Output
|
||||
|
||||
Report your findings as structured data:
|
||||
- **repoPath**: absolute path to the local repo
|
||||
- **defaultBranch**: the default branch name (e.g. "main")
|
||||
- **conventions**: a summary of project conventions found, or null if none
|
||||
- **toolchain**: detected commands for packageManager, testCommand, lintCommand, buildCommand (null if not detected)`;
|
||||
|
||||
export const preparerRole: RoleDefinition<PreparerMeta> = {
|
||||
description:
|
||||
"Locates or clones the target repository, ensures it is up to date, and gathers project context (conventions, toolchain).",
|
||||
systemPrompt: PREPARER_SYSTEM,
|
||||
extractPrompt:
|
||||
"Extract repoPath (absolute path), defaultBranch, conventions (summary string or null), and toolchain (packageManager, testCommand, lintCommand, buildCommand — each string or null).",
|
||||
schema: preparerMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,15 +1,98 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { afterEach, describe, expect, mock, test } from "bun:test";
|
||||
|
||||
import { reviewerMetaSchema, reviewerRole } from "../src/reviewer.js";
|
||||
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
|
||||
import { START } from "@uncaged/workflow";
|
||||
|
||||
describe("reviewerRole", () => {
|
||||
test("approved sample validates against schema", () => {
|
||||
const parsed = reviewerMetaSchema.safeParse({ status: "approved" as const });
|
||||
expect(parsed.success).toBe(true);
|
||||
import { createReviewerRole, DEFAULT_REVIEWER_CONFIG } from "../src/reviewer.js";
|
||||
|
||||
function toolCallResponse(argsJson: string): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: "extract",
|
||||
arguments: argsJson,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
function makeCtx(): ThreadContext {
|
||||
return {
|
||||
start: {
|
||||
role: START,
|
||||
content: "task",
|
||||
meta: { maxRounds: 10 },
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
steps: [],
|
||||
};
|
||||
}
|
||||
|
||||
const provider = { baseUrl: "https://example.com/v1", apiKey: "k", model: "m" };
|
||||
|
||||
describe("createReviewerRole", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
test("system prompt is generic (no cwd)", () => {
|
||||
expect(reviewerRole.systemPrompt).toContain("code reviewer");
|
||||
expect(reviewerRole.systemPrompt).not.toContain("project is at");
|
||||
test("runs reviewer extract", async () => {
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
toolCallResponse(JSON.stringify({ approved: true })),
|
||||
)) as unknown as typeof fetch;
|
||||
|
||||
const agent: AgentFn = async (_ctx, prompt) => {
|
||||
expect(prompt).toContain("git diff");
|
||||
expect(prompt).toContain(DEFAULT_REVIEWER_CONFIG.cwd);
|
||||
return "review done";
|
||||
};
|
||||
|
||||
const role = createReviewerRole(agent, {
|
||||
provider,
|
||||
dryRun: null,
|
||||
dryRunMeta: { approved: true },
|
||||
});
|
||||
const out = await role(makeCtx());
|
||||
expect(out.meta).toEqual({ approved: true });
|
||||
});
|
||||
|
||||
test("includes uncaged-workflow thread hint when threadId set", async () => {
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
toolCallResponse(JSON.stringify({ approved: false })),
|
||||
)) as unknown as typeof fetch;
|
||||
|
||||
let seen = "";
|
||||
const agent: AgentFn = async (_ctx, prompt) => {
|
||||
seen = prompt;
|
||||
return "x";
|
||||
};
|
||||
|
||||
const role = createReviewerRole(
|
||||
agent,
|
||||
{ provider, dryRun: null, dryRunMeta: { approved: false } },
|
||||
{
|
||||
cwd: "/proj",
|
||||
conventionsPath: null,
|
||||
extraChecks: [],
|
||||
threadId: "01ABCDEF234567890ABCDEFGH",
|
||||
},
|
||||
);
|
||||
await role(makeCtx());
|
||||
expect(seen).toContain("uncaged-workflow thread 01ABCDEF234567890ABCDEFGH");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-role-llm": "workspace:*",
|
||||
"@uncaged/workflow-util-role": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
export { type ReviewerMeta, reviewerMetaSchema, reviewerRole } from "./reviewer.js";
|
||||
export {
|
||||
createReviewerRole,
|
||||
DEFAULT_REVIEWER_CONFIG,
|
||||
type ReviewerConfig,
|
||||
type ReviewerMeta,
|
||||
reviewerMetaSchema,
|
||||
} from "./reviewer.js";
|
||||
|
||||
@@ -1,26 +1,108 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
|
||||
import { createRole } from "@uncaged/workflow-role-llm";
|
||||
import type { LlmProvider } from "@uncaged/workflow-util-role";
|
||||
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 const reviewerMetaSchema = z.object({
|
||||
approved: z.boolean().describe("true if the diff is clean and ready to merge"),
|
||||
});
|
||||
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",
|
||||
export type ReviewerConfig = {
|
||||
cwd: string;
|
||||
conventionsPath: string | null;
|
||||
extraChecks: ReadonlyArray<string>;
|
||||
/** When non-null, prompts reference `uncaged-workflow thread <id>`. */
|
||||
threadId: string | null;
|
||||
};
|
||||
|
||||
export const DEFAULT_REVIEWER_CONFIG: ReviewerConfig = {
|
||||
cwd: ".",
|
||||
conventionsPath: "CONVENTIONS.md",
|
||||
extraChecks: [],
|
||||
threadId: null,
|
||||
};
|
||||
|
||||
function summarizeThreadContext(ctx: ThreadContext): string {
|
||||
const lines: string[] = [`Initial prompt:\n${ctx.start.content}`];
|
||||
for (const step of ctx.steps) {
|
||||
const snippet = step.content.length > 600 ? `${step.content.slice(0, 600)}…` : step.content;
|
||||
lines.push(`\n### ${step.role}\n${snippet}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function reviewerPrompt(config: ReviewerConfig, ctx: ThreadContext): string {
|
||||
const { cwd, conventionsPath, extraChecks, threadId } = config;
|
||||
|
||||
const conventionsBlock =
|
||||
conventionsPath !== null ? `Read project conventions: \`cat ${cwd}/${conventionsPath}\`\n` : "";
|
||||
|
||||
const threadBlock =
|
||||
threadId !== null
|
||||
? `Read the workflow thread for context: \`uncaged-workflow thread ${threadId}\`\n`
|
||||
: `## Thread context (no thread id)\n\n${summarizeThreadContext(ctx)}\n`;
|
||||
|
||||
const extraBlock =
|
||||
extraChecks.length > 0
|
||||
? `\n### Project-specific checks\n${extraChecks.map((c) => `- ${c}`).join("\n")}\n`
|
||||
: "";
|
||||
|
||||
return `You are a **code reviewer**. You run after the coder and before the tester.
|
||||
|
||||
**IMPORTANT: The project is at \`${cwd}\`. Always \`cd ${cwd}\` first.**
|
||||
|
||||
${threadBlock}
|
||||
${conventionsBlock}
|
||||
## Your job — static analysis of the git diff
|
||||
|
||||
Run these commands and analyze the output:
|
||||
|
||||
1. **\`cd ${cwd} && git diff --stat\`** — see what files changed
|
||||
2. **\`cd ${cwd} && git diff\`** — read the actual diff
|
||||
3. **\`cd ${cwd} && git status --short\`** — check for untracked files
|
||||
|
||||
## Checklist
|
||||
|
||||
### Reject (approved: false) — tell coder exactly what to fix
|
||||
- **Garbage files**: build artifacts, lockfiles, IDE config that should not be committed
|
||||
- **Secrets/credentials**: API keys, tokens, passwords hardcoded in the diff
|
||||
- **Unrelated changes**: files modified outside the scope of the task
|
||||
${
|
||||
conventionsPath !== null
|
||||
? `- **Convention violations**: patterns that contradict ${conventionsPath}\n`
|
||||
: ""
|
||||
}${extraBlock}
|
||||
### Approve (approved: true) — no comment needed
|
||||
- Diff is clean, focused, follows project standards
|
||||
|
||||
End with:
|
||||
\`\`\`json
|
||||
{ "approved": true }
|
||||
\`\`\`
|
||||
or
|
||||
\`\`\`json
|
||||
{ "approved": false }
|
||||
\`\`\``;
|
||||
}
|
||||
|
||||
/**
|
||||
* Code review role: agent inspects git diffs; structured extract yields `approved`.
|
||||
*/
|
||||
export function createReviewerRole(
|
||||
adapter: AgentFn,
|
||||
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: ReviewerMeta },
|
||||
config: ReviewerConfig = DEFAULT_REVIEWER_CONFIG,
|
||||
): Role<ReviewerMeta> {
|
||||
return createRole({
|
||||
name: "reviewer",
|
||||
schema: reviewerMetaSchema,
|
||||
systemPrompt: async (ctx) => reviewerPrompt(config, ctx),
|
||||
agent: adapter,
|
||||
extract: {
|
||||
provider: extract.provider,
|
||||
dryRun: extract.dryRun,
|
||||
dryRunMeta: extract.dryRunMeta,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,5 +6,9 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }]
|
||||
"references": [
|
||||
{ "path": "../workflow" },
|
||||
{ "path": "../workflow-role-llm" },
|
||||
{ "path": "../workflow-util-role" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,99 +1,17 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { RoleMeta } from "@uncaged/workflow";
|
||||
import {
|
||||
createCasStore,
|
||||
createExtract,
|
||||
END,
|
||||
type ModeratorContext,
|
||||
type RoleStep,
|
||||
START,
|
||||
type ThreadContext,
|
||||
validateWorkflowDescriptor,
|
||||
} 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 { createSolveIssueRun, solveIssueModerator } from "../src/index.js";
|
||||
import type { SolveIssueMeta } from "../src/roles.js";
|
||||
import { solveIssueModerator } from "../src/moderator.js";
|
||||
import { createSolveIssueRoles, type SolveIssueMeta } from "../src/roles.js";
|
||||
|
||||
const DEFAULT_PHASES: PlannerMeta["phases"] = [
|
||||
{
|
||||
hash: "4KNMR2PX",
|
||||
title: "Do the work",
|
||||
},
|
||||
];
|
||||
|
||||
const EXPECT_PLANNER_META: PlannerMeta = {
|
||||
phases: [
|
||||
{
|
||||
hash: "7BQST3VW",
|
||||
title: "placeholder phase",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const EXPECT_CODER_META: CoderMeta = {
|
||||
completedPhase: "7BQST3VW",
|
||||
filesChanged: [],
|
||||
summary: "",
|
||||
};
|
||||
|
||||
function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unknown>>): () => void {
|
||||
const origFetch = globalThis.fetch;
|
||||
let i = 0;
|
||||
const mockFetch = async (
|
||||
input: Parameters<typeof fetch>[0],
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||
if (args === undefined) {
|
||||
throw new Error("installMockChatCompletions: empty sequence");
|
||||
}
|
||||
i += 1;
|
||||
void input;
|
||||
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
|
||||
const tools = body.tools;
|
||||
const firstTool =
|
||||
Array.isArray(tools) && tools.length > 0 && tools[0] !== null && typeof tools[0] === "object"
|
||||
? (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, {
|
||||
preconnect: origFetch.preconnect.bind(origFetch),
|
||||
}) as typeof fetch;
|
||||
return () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
}
|
||||
|
||||
function makeStart(maxRounds: number): ModeratorContext<SolveIssueMeta>["start"] {
|
||||
function makeStart(maxRounds: number): ThreadContext<SolveIssueMeta>["start"] {
|
||||
return {
|
||||
role: START,
|
||||
content: "Fix the flaky login test",
|
||||
@@ -104,52 +22,28 @@ function makeStart(maxRounds: number): ModeratorContext<SolveIssueMeta>["start"]
|
||||
|
||||
function makeCtx(
|
||||
maxRounds: number,
|
||||
steps: ModeratorContext<SolveIssueMeta>["steps"],
|
||||
): ModeratorContext<SolveIssueMeta> {
|
||||
steps: ThreadContext<SolveIssueMeta>["steps"],
|
||||
): ThreadContext<SolveIssueMeta> {
|
||||
return {
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
depth: 0,
|
||||
start: makeStart(maxRounds),
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
function preparerStep(): RoleStep<SolveIssueMeta> {
|
||||
return {
|
||||
role: "preparer",
|
||||
contentHash: "STUBHASHPREPARER01",
|
||||
meta: {
|
||||
repoPath: "/home/user/repos/test",
|
||||
defaultBranch: "main",
|
||||
conventions: null,
|
||||
toolchain: {
|
||||
packageManager: "bun",
|
||||
testCommand: "bun test",
|
||||
lintCommand: null,
|
||||
buildCommand: "bun run build",
|
||||
},
|
||||
},
|
||||
refs: [],
|
||||
timestamp: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function plannerStep(phases: PlannerMeta["phases"] = DEFAULT_PHASES): RoleStep<SolveIssueMeta> {
|
||||
function plannerStep(): RoleStep<SolveIssueMeta> {
|
||||
return {
|
||||
role: "planner",
|
||||
contentHash: "STUBHASHPLANNER001",
|
||||
meta: { phases },
|
||||
refs: phases.map((p) => p.hash),
|
||||
content: "plan",
|
||||
meta: { plan: "do work", files: ["a.ts"], approach: "minimal fix" },
|
||||
timestamp: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function coderStep(completedPhase = "4KNMR2PX"): RoleStep<SolveIssueMeta> {
|
||||
function coderStep(): RoleStep<SolveIssueMeta> {
|
||||
return {
|
||||
role: "coder",
|
||||
contentHash: "STUBHASHCODER00001",
|
||||
meta: { completedPhase, filesChanged: ["a.ts"], summary: "fixed" },
|
||||
refs: [completedPhase],
|
||||
content: "code",
|
||||
meta: { filesChanged: ["a.ts"], summary: "fixed" },
|
||||
timestamp: 2,
|
||||
};
|
||||
}
|
||||
@@ -157,11 +51,8 @@ function coderStep(completedPhase = "4KNMR2PX"): RoleStep<SolveIssueMeta> {
|
||||
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: [],
|
||||
content: "rev",
|
||||
meta: { approved },
|
||||
timestamp: 3,
|
||||
};
|
||||
}
|
||||
@@ -169,47 +60,29 @@ function reviewerStep(approved: boolean): RoleStep<SolveIssueMeta> {
|
||||
function committerStep(): RoleStep<SolveIssueMeta> {
|
||||
return {
|
||||
role: "committer",
|
||||
contentHash: "STUBHASHCOMMITTER1",
|
||||
meta: { status: "committed", branch: "feat/issue-1", commitSha: "abc1234" },
|
||||
refs: [],
|
||||
content: "commit",
|
||||
meta: { committed: true },
|
||||
timestamp: 4,
|
||||
};
|
||||
}
|
||||
|
||||
const stubExtract = createExtract({
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "",
|
||||
model: "test",
|
||||
});
|
||||
|
||||
describe("solveIssueModerator", () => {
|
||||
test("routes preparer → planner → coder → reviewer → committer → END", () => {
|
||||
expect(solveIssueModerator(makeCtx(20, []))).toBe("preparer");
|
||||
expect(solveIssueModerator(makeCtx(20, [preparerStep()]))).toBe("planner");
|
||||
expect(solveIssueModerator(makeCtx(20, [preparerStep(), plannerStep()]))).toBe("coder");
|
||||
expect(solveIssueModerator(makeCtx(20, [preparerStep(), plannerStep(), coderStep()]))).toBe(
|
||||
"reviewer",
|
||||
test("routes planner → coder → reviewer → committer → END", () => {
|
||||
expect(solveIssueModerator(makeCtx(20, []))).toBe("planner");
|
||||
expect(solveIssueModerator(makeCtx(20, [plannerStep()]))).toBe("coder");
|
||||
expect(solveIssueModerator(makeCtx(20, [plannerStep(), coderStep()]))).toBe("reviewer");
|
||||
expect(solveIssueModerator(makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true)]))).toBe(
|
||||
"committer",
|
||||
);
|
||||
expect(
|
||||
solveIssueModerator(
|
||||
makeCtx(20, [preparerStep(), plannerStep(), coderStep(), reviewerStep(true)]),
|
||||
),
|
||||
).toBe("committer");
|
||||
expect(
|
||||
solveIssueModerator(
|
||||
makeCtx(20, [
|
||||
preparerStep(),
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(true),
|
||||
committerStep(),
|
||||
]),
|
||||
makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true), committerStep()]),
|
||||
),
|
||||
).toBe(END);
|
||||
});
|
||||
|
||||
test("reviewer rejects → coder retry when budget allows", () => {
|
||||
const steps: ModeratorContext<SolveIssueMeta>["steps"] = [
|
||||
const steps: ThreadContext<SolveIssueMeta>["steps"] = [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(false),
|
||||
@@ -218,178 +91,33 @@ describe("solveIssueModerator", () => {
|
||||
});
|
||||
|
||||
test("reviewer rejects → END when max rounds exhausted", () => {
|
||||
const steps: ModeratorContext<SolveIssueMeta>["steps"] = [
|
||||
const steps: ThreadContext<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(
|
||||
solveIssueModerator(
|
||||
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: "commit and pr" },
|
||||
];
|
||||
expect(solveIssueModerator(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(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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSolveIssueRun", () => {
|
||||
let restoreFetch: (() => void) | null = null;
|
||||
let casDir: string | undefined;
|
||||
describe("createSolveIssueRoles", () => {
|
||||
test("returns all four role callables", async () => {
|
||||
const agent = async () => '{"plan":"x","files":[],"approach":"y"}';
|
||||
const roles = createSolveIssueRoles({
|
||||
agent,
|
||||
workdir: "/tmp/repo",
|
||||
extract: null,
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
restoreFetch?.();
|
||||
restoreFetch = null;
|
||||
if (casDir !== undefined) {
|
||||
await rm(casDir, { recursive: true, force: true }).catch(() => {});
|
||||
casDir = undefined;
|
||||
}
|
||||
});
|
||||
expect(typeof roles.planner).toBe("function");
|
||||
expect(typeof roles.coder).toBe("function");
|
||||
expect(typeof roles.reviewer).toBe("function");
|
||||
expect(typeof roles.committer).toBe("function");
|
||||
|
||||
test("structured extraction yields preparer then planner meta from mocked chat completions", async () => {
|
||||
const EXPECT_PREPARER_META: PreparerMeta = {
|
||||
repoPath: "/home/user/repos/test",
|
||||
defaultBranch: "main",
|
||||
conventions: null,
|
||||
toolchain: {
|
||||
packageManager: "bun",
|
||||
testCommand: "bun test",
|
||||
lintCommand: null,
|
||||
buildCommand: "bun run build",
|
||||
},
|
||||
};
|
||||
restoreFetch = installMockChatCompletions([EXPECT_PREPARER_META, EXPECT_PLANNER_META]);
|
||||
|
||||
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
const run = createSolveIssueRun({ agent: async () => "" }, stubExtract, null);
|
||||
const gen = run(
|
||||
{ prompt: "task", steps: [] },
|
||||
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
|
||||
);
|
||||
const first = await gen.next();
|
||||
expect(first.done).toBe(false);
|
||||
if (first.done) {
|
||||
throw new Error("expected yield");
|
||||
}
|
||||
expect(first.value.role).toBe("preparer");
|
||||
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 () => {
|
||||
const PREPARER_META: PreparerMeta = {
|
||||
repoPath: "/tmp/r",
|
||||
defaultBranch: "main",
|
||||
conventions: null,
|
||||
toolchain: { packageManager: null, testCommand: null, lintCommand: null, buildCommand: null },
|
||||
};
|
||||
restoreFetch = installMockChatCompletions([
|
||||
PREPARER_META,
|
||||
EXPECT_PLANNER_META,
|
||||
EXPECT_CODER_META,
|
||||
]);
|
||||
|
||||
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
const calls: string[] = [];
|
||||
const run = createSolveIssueRun(
|
||||
{
|
||||
agent: async () => {
|
||||
calls.push("default");
|
||||
return "";
|
||||
},
|
||||
overrides: {
|
||||
preparer: async () => {
|
||||
calls.push("preparer");
|
||||
return "";
|
||||
},
|
||||
planner: async () => {
|
||||
calls.push("planner");
|
||||
return "";
|
||||
},
|
||||
coder: async () => {
|
||||
calls.push("coder");
|
||||
return "";
|
||||
},
|
||||
},
|
||||
},
|
||||
stubExtract,
|
||||
null,
|
||||
);
|
||||
const gen = run(
|
||||
{ prompt: "task", steps: [] },
|
||||
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
|
||||
);
|
||||
await gen.next();
|
||||
expect(calls).toEqual(["preparer"]);
|
||||
|
||||
calls.length = 0;
|
||||
await gen.next();
|
||||
expect(calls).toEqual(["planner"]);
|
||||
|
||||
calls.length = 0;
|
||||
await gen.next();
|
||||
expect(calls).toEqual(["coder"]);
|
||||
const ctx = makeCtx(10, []) as unknown as ThreadContext<RoleMeta>;
|
||||
const plannerOut = await roles.planner(ctx);
|
||||
expect(plannerOut.meta.plan).toBe("");
|
||||
expect(Array.isArray(plannerOut.meta.files)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -405,10 +133,9 @@ describe("buildSolveIssueDescriptor", () => {
|
||||
"coder",
|
||||
"committer",
|
||||
"planner",
|
||||
"preparer",
|
||||
"reviewer",
|
||||
]);
|
||||
for (const key of ["preparer", "planner", "coder", "reviewer", "committer"] as const) {
|
||||
for (const key of ["planner", "coder", "reviewer", "committer"] as const) {
|
||||
const role = validated.value.roles[key];
|
||||
expect(role).toBeDefined();
|
||||
expect(typeof role.schema).toBe("object");
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-agent-cursor": "workspace:*",
|
||||
"@uncaged/workflow-role-committer": "workspace:*",
|
||||
"@uncaged/workflow-role-coder": "workspace:*",
|
||||
"@uncaged/workflow-role-planner": "workspace:*",
|
||||
"@uncaged/workflow-role-preparer": "workspace:*",
|
||||
"@uncaged/workflow-role-reviewer": "workspace:*"
|
||||
"@uncaged/workflow-role-llm": "workspace:*",
|
||||
"@uncaged/workflow-role-reviewer": "workspace:*",
|
||||
"@uncaged/workflow-util-role": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
import { buildDescriptor } from "@uncaged/workflow";
|
||||
import { committerMetaSchema } from "@uncaged/workflow-role-committer";
|
||||
import { reviewerMetaSchema } from "@uncaged/workflow-role-reviewer";
|
||||
import { buildDescriptorFromRoles } from "@uncaged/workflow-util-role";
|
||||
|
||||
import { solveIssueModerator } from "./moderator.js";
|
||||
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, solveIssueRoles } from "./roles.js";
|
||||
import { coderMetaSchema, plannerMetaSchema } from "./roles.js";
|
||||
|
||||
export function buildSolveIssueDescriptor() {
|
||||
return buildDescriptor({
|
||||
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||
roles: solveIssueRoles,
|
||||
moderator: solveIssueModerator,
|
||||
return buildDescriptorFromRoles({
|
||||
description:
|
||||
"Plan, implement, review, and commit changes to resolve an issue end-to-end (planner → coder → reviewer → committer).",
|
||||
roles: {
|
||||
planner: {
|
||||
name: "planner",
|
||||
schema: plannerMetaSchema,
|
||||
description: "Analyzes the issue and proposes plan, files, and approach.",
|
||||
},
|
||||
coder: {
|
||||
name: "coder",
|
||||
schema: coderMetaSchema,
|
||||
description: "Implements the planner output and summarizes touched files.",
|
||||
},
|
||||
reviewer: {
|
||||
name: "reviewer",
|
||||
schema: reviewerMetaSchema,
|
||||
description: "Runs git diff checks and sets approved when the change is ready.",
|
||||
},
|
||||
committer: {
|
||||
name: "committer",
|
||||
schema: committerMetaSchema,
|
||||
description: "Creates branch, commits, and pushes when review passes.",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,60 +1,29 @@
|
||||
import {
|
||||
type AgentBinding,
|
||||
createWorkflow,
|
||||
type ExtractFn,
|
||||
type LlmProvider,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowFn,
|
||||
} from "@uncaged/workflow";
|
||||
import { createRoleModerator, type WorkflowFn } from "@uncaged/workflow";
|
||||
|
||||
import { solveIssueModerator } from "./moderator.js";
|
||||
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
|
||||
import { createSolveIssueRoles, type SolveIssueMeta, type SolveIssueRolesConfig } from "./roles.js";
|
||||
|
||||
export {
|
||||
type CoderMeta,
|
||||
coderMetaSchema,
|
||||
coderRole,
|
||||
} from "@uncaged/workflow-role-coder";
|
||||
export {
|
||||
type CommitterMeta,
|
||||
committerMetaSchema,
|
||||
committerRole,
|
||||
} from "@uncaged/workflow-role-committer";
|
||||
export {
|
||||
type PlannerMeta,
|
||||
phaseSchema,
|
||||
plannerMetaSchema,
|
||||
plannerRole,
|
||||
} from "@uncaged/workflow-role-planner";
|
||||
export {
|
||||
type PreparerMeta,
|
||||
preparerMetaSchema,
|
||||
preparerRole,
|
||||
} from "@uncaged/workflow-role-preparer";
|
||||
export {
|
||||
type ReviewerMeta,
|
||||
reviewerMetaSchema,
|
||||
reviewerRole,
|
||||
} from "@uncaged/workflow-role-reviewer";
|
||||
export { type CursorAgentConfig, createCursorAgent } from "@uncaged/workflow-agent-cursor";
|
||||
export { buildSolveIssueDescriptor } from "./descriptor.js";
|
||||
export { solveIssueModerator } from "./moderator.js";
|
||||
export {
|
||||
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||
type CoderMeta,
|
||||
coderMetaSchema,
|
||||
createSolveIssueRoles,
|
||||
type PlannerMeta,
|
||||
plannerMetaSchema,
|
||||
type SolveIssueMeta,
|
||||
type SolveIssueRoles,
|
||||
solveIssueRoles,
|
||||
type SolveIssueRolesConfig,
|
||||
} from "./roles.js";
|
||||
|
||||
export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> = {
|
||||
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
|
||||
roles: solveIssueRoles,
|
||||
moderator: solveIssueModerator,
|
||||
};
|
||||
|
||||
export function createSolveIssueRun(
|
||||
binding: AgentBinding,
|
||||
extract: ExtractFn,
|
||||
llmProvider: LlmProvider | null,
|
||||
): WorkflowFn {
|
||||
return createWorkflow(solveIssueWorkflowDefinition, binding, extract, llmProvider);
|
||||
/**
|
||||
* Factory for a {@link WorkflowFn}: supply an agent and repo paths at runtime, then pass the result
|
||||
* to the bundle `run` export pattern (`createRoleModerator` is already applied).
|
||||
*/
|
||||
export function createSolveIssueRun(config: SolveIssueRolesConfig): WorkflowFn {
|
||||
return createRoleModerator<SolveIssueMeta>({
|
||||
roles: createSolveIssueRoles(config),
|
||||
moderator: solveIssueModerator,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,72 +1,27 @@
|
||||
import type { Moderator, ModeratorContext } from "@uncaged/workflow";
|
||||
import type { Moderator } from "@uncaged/workflow";
|
||||
import { END } from "@uncaged/workflow";
|
||||
|
||||
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) => {
|
||||
const maxRounds = ctx.start.meta.maxRounds;
|
||||
|
||||
if (ctx.steps.length === 0) {
|
||||
return "preparer";
|
||||
return "planner";
|
||||
}
|
||||
|
||||
const last = ctx.steps[ctx.steps.length - 1];
|
||||
|
||||
if (last.role === "preparer") {
|
||||
return "planner";
|
||||
}
|
||||
|
||||
if (last.role === "planner") {
|
||||
return "coder";
|
||||
}
|
||||
|
||||
if (last.role === "coder") {
|
||||
return nextAfterCoder(ctx, maxRounds);
|
||||
return "reviewer";
|
||||
}
|
||||
|
||||
if (last.role === "reviewer") {
|
||||
if (last.meta.status === "approved") {
|
||||
if (last.meta.approved === true) {
|
||||
return "committer";
|
||||
}
|
||||
if (ctx.steps.length < maxRounds - 1) {
|
||||
@@ -76,9 +31,6 @@ export const solveIssueModerator: Moderator<SolveIssueMeta> = (ctx) => {
|
||||
}
|
||||
|
||||
if (last.role === "committer") {
|
||||
if (last.meta.status === "recoverable" && ctx.steps.length < maxRounds - 1) {
|
||||
return "coder";
|
||||
}
|
||||
return END;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,151 @@
|
||||
import type { RoleDefinition } from "@uncaged/workflow";
|
||||
import { type CoderMeta, coderRole } from "@uncaged/workflow-role-coder";
|
||||
import { type CommitterMeta, committerRole } from "@uncaged/workflow-role-committer";
|
||||
import { type PlannerMeta, plannerRole } from "@uncaged/workflow-role-planner";
|
||||
import { type PreparerMeta, preparerRole } from "@uncaged/workflow-role-preparer";
|
||||
import { type ReviewerMeta, reviewerRole } from "@uncaged/workflow-role-reviewer";
|
||||
import type { AgentFn, Role } from "@uncaged/workflow";
|
||||
import {
|
||||
type CommitterMeta,
|
||||
type CommitterPlanMeta,
|
||||
createCommitterRole,
|
||||
} from "@uncaged/workflow-role-committer";
|
||||
import { createRole } from "@uncaged/workflow-role-llm";
|
||||
import { createReviewerRole, type ReviewerMeta } from "@uncaged/workflow-role-reviewer";
|
||||
import type { LlmProvider } from "@uncaged/workflow-util-role";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
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).";
|
||||
const DRY_RUN_PROVIDER: LlmProvider = {
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "",
|
||||
model: "template-dry-run",
|
||||
};
|
||||
|
||||
const PLANNER_SYSTEM = `You are a **planner** for a software task. Analyze the issue, list relevant files, and produce a clear step-by-step approach.
|
||||
|
||||
Focus on: root cause, edge cases, and how the implementation will be verified. Output enough detail for a coding agent to implement without guessing.`;
|
||||
|
||||
const CODER_SYSTEM = `You are a **coder**. The previous step produced a plan: read the thread and implement that plan in the repository.
|
||||
|
||||
Make focused changes, follow project conventions, and explain what you changed.`;
|
||||
|
||||
export const plannerMetaSchema = z.object({
|
||||
plan: z.string(),
|
||||
files: z.array(z.string()),
|
||||
approach: z.string(),
|
||||
});
|
||||
|
||||
export const coderMetaSchema = z.object({
|
||||
filesChanged: z.array(z.string()),
|
||||
summary: z.string(),
|
||||
});
|
||||
|
||||
export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
|
||||
|
||||
export type CoderMeta = z.infer<typeof coderMetaSchema>;
|
||||
|
||||
const PLANNER_DRY_RUN_META: PlannerMeta = {
|
||||
plan: "",
|
||||
files: [],
|
||||
approach: "",
|
||||
};
|
||||
|
||||
const CODER_DRY_RUN_META: CoderMeta = {
|
||||
filesChanged: [],
|
||||
summary: "",
|
||||
};
|
||||
|
||||
const REVIEWER_DRY_RUN_META: ReviewerMeta = {
|
||||
approved: true,
|
||||
};
|
||||
|
||||
const COMMITTER_PLAN_DRY_RUN_META: CommitterPlanMeta = {
|
||||
branch: "dry-run",
|
||||
message: "chore: dry run",
|
||||
};
|
||||
|
||||
export type SolveIssueMeta = {
|
||||
preparer: PreparerMeta;
|
||||
planner: PlannerMeta;
|
||||
coder: CoderMeta;
|
||||
reviewer: ReviewerMeta;
|
||||
committer: CommitterMeta;
|
||||
};
|
||||
|
||||
export type SolveIssueRoles = {
|
||||
[K in keyof SolveIssueMeta]: RoleDefinition<SolveIssueMeta[K]>;
|
||||
/** Wiring for workflow-role LLM structured extraction. Use `null` for stub extract (dry-run meta from built-in placeholders). */
|
||||
export type SolveIssueRolesConfig = {
|
||||
agent: AgentFn;
|
||||
workdir: string;
|
||||
extract: { provider: LlmProvider; dryRun: boolean | null } | null;
|
||||
};
|
||||
|
||||
export const solveIssueRoles: SolveIssueRoles = {
|
||||
preparer: preparerRole,
|
||||
planner: plannerRole,
|
||||
coder: coderRole,
|
||||
reviewer: reviewerRole,
|
||||
committer: committerRole,
|
||||
function resolveExtract(config: SolveIssueRolesConfig): {
|
||||
provider: LlmProvider;
|
||||
dryRun: boolean | null;
|
||||
} {
|
||||
if (config.extract === null) {
|
||||
return { provider: DRY_RUN_PROVIDER, dryRun: true };
|
||||
}
|
||||
return config.extract;
|
||||
}
|
||||
|
||||
export type SolveIssueRoles = {
|
||||
planner: Role<PlannerMeta>;
|
||||
coder: Role<CoderMeta>;
|
||||
reviewer: Role<ReviewerMeta>;
|
||||
committer: Role<CommitterMeta>;
|
||||
};
|
||||
|
||||
export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssueRoles {
|
||||
const extract = resolveExtract(config);
|
||||
const reviewerGit = {
|
||||
cwd: config.workdir,
|
||||
conventionsPath: null,
|
||||
extraChecks: [],
|
||||
threadId: null,
|
||||
};
|
||||
const committerGit = {
|
||||
cwd: config.workdir,
|
||||
remote: "origin",
|
||||
threadId: null,
|
||||
};
|
||||
|
||||
const planner: Role<PlannerMeta> = createRole({
|
||||
name: "planner",
|
||||
schema: plannerMetaSchema,
|
||||
systemPrompt: PLANNER_SYSTEM,
|
||||
agent: config.agent,
|
||||
extract: {
|
||||
provider: extract.provider,
|
||||
dryRun: extract.dryRun,
|
||||
dryRunMeta: PLANNER_DRY_RUN_META,
|
||||
},
|
||||
});
|
||||
|
||||
const coder: Role<CoderMeta> = createRole({
|
||||
name: "coder",
|
||||
schema: coderMetaSchema,
|
||||
systemPrompt: CODER_SYSTEM,
|
||||
agent: config.agent,
|
||||
extract: {
|
||||
provider: extract.provider,
|
||||
dryRun: extract.dryRun,
|
||||
dryRunMeta: CODER_DRY_RUN_META,
|
||||
},
|
||||
});
|
||||
|
||||
const reviewer: Role<ReviewerMeta> = createReviewerRole(
|
||||
config.agent,
|
||||
{
|
||||
provider: extract.provider,
|
||||
dryRun: extract.dryRun,
|
||||
dryRunMeta: REVIEWER_DRY_RUN_META,
|
||||
},
|
||||
reviewerGit,
|
||||
);
|
||||
|
||||
const committer: Role<CommitterMeta> = createCommitterRole(
|
||||
config.agent,
|
||||
{
|
||||
provider: extract.provider,
|
||||
dryRun: extract.dryRun,
|
||||
dryRunMeta: COMMITTER_PLAN_DRY_RUN_META,
|
||||
},
|
||||
committerGit,
|
||||
);
|
||||
|
||||
return { planner, coder, reviewer, committer };
|
||||
}
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../workflow" },
|
||||
{ "path": "../workflow-role-coder" },
|
||||
{ "path": "../workflow-role-committer" },
|
||||
{ "path": "../workflow-role-planner" },
|
||||
{ "path": "../workflow-role-reviewer" }
|
||||
{ "path": "../workflow-role-llm" },
|
||||
{ "path": "../workflow-util-role" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, putContentMerkleNode, START, type ThreadContext } from "@uncaged/workflow";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { START, type ThreadContext } from "@uncaged/workflow";
|
||||
|
||||
import { buildAgentPrompt } from "../src/index.js";
|
||||
|
||||
@@ -16,90 +13,59 @@ function startTask(content: string): ThreadContext["start"] {
|
||||
}
|
||||
|
||||
describe("buildAgentPrompt", () => {
|
||||
let casRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
casRoot = await mkdtemp(join(tmpdir(), "wf-build-prompt-cas-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(casRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("includes system prompt and full task; omits tools when there are no steps", async () => {
|
||||
const cas = createCasStore(casRoot);
|
||||
test("includes system prompt and full task; omits tools when there are no steps", () => {
|
||||
const ctx: ThreadContext = {
|
||||
start: startTask("fix the bug"),
|
||||
depth: 0,
|
||||
steps: [],
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: START, systemPrompt: "You are an agent." },
|
||||
cas,
|
||||
};
|
||||
const text = await buildAgentPrompt(ctx);
|
||||
const text = buildAgentPrompt("You are an agent.", ctx);
|
||||
expect(text).toContain("You are an agent.");
|
||||
expect(text).toContain("## Task");
|
||||
expect(text).toContain("fix the bug");
|
||||
expect(text).not.toContain("## Tools");
|
||||
});
|
||||
|
||||
test("single step shows full content and meta, and includes tools", async () => {
|
||||
const cas = createCasStore(casRoot);
|
||||
const onlyHash = await putContentMerkleNode(cas, "only step full body");
|
||||
test("single step shows full content and meta, and includes tools", () => {
|
||||
const ctx: ThreadContext = {
|
||||
start: startTask("user task"),
|
||||
depth: 0,
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "coder", systemPrompt: "Be helpful." },
|
||||
cas,
|
||||
steps: [
|
||||
{
|
||||
role: "coder",
|
||||
contentHash: onlyHash,
|
||||
content: "only step full body",
|
||||
meta: { files: ["a.ts"] },
|
||||
refs: [onlyHash],
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = await buildAgentPrompt(ctx);
|
||||
const text = buildAgentPrompt("Be helpful.", ctx);
|
||||
expect(text).toContain("## Task");
|
||||
expect(text).toContain("user task");
|
||||
expect(text).toContain("## Step: coder");
|
||||
expect(text).toContain("only step full body");
|
||||
expect(text).toContain('Meta: {"files":["a.ts"]}');
|
||||
expect(text).toContain("## Tools");
|
||||
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
||||
expect(text).toContain("uncaged-workflow thread <threadId>");
|
||||
});
|
||||
|
||||
test("two or more steps: previous steps are meta-only; latest step is full", async () => {
|
||||
const cas = createCasStore(casRoot);
|
||||
const plannerHash = await putContentMerkleNode(cas, "PLANNER_SECRET_FULL_TEXT");
|
||||
const coderHash = await putContentMerkleNode(cas, "last step full content");
|
||||
test("two or more steps: previous steps are meta-only; latest step is full", () => {
|
||||
const ctx: ThreadContext = {
|
||||
start: startTask("first message full: task content here"),
|
||||
depth: 0,
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "coder", systemPrompt: "System." },
|
||||
cas,
|
||||
steps: [
|
||||
{
|
||||
role: "planner",
|
||||
contentHash: plannerHash,
|
||||
content: "PLANNER_SECRET_FULL_TEXT",
|
||||
meta: { plan: "short" },
|
||||
refs: [plannerHash],
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
role: "coder",
|
||||
contentHash: coderHash,
|
||||
content: "last step full content",
|
||||
meta: { done: true },
|
||||
refs: [coderHash],
|
||||
timestamp: 3,
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = await buildAgentPrompt(ctx);
|
||||
const text = buildAgentPrompt("System.", ctx);
|
||||
expect(text).toContain("first message full: task content here");
|
||||
expect(text).toContain("## Previous Steps");
|
||||
expect(text).toContain("### Step 1: planner");
|
||||
@@ -109,45 +75,33 @@ describe("buildAgentPrompt", () => {
|
||||
expect(text).toContain("last step full content");
|
||||
expect(text).toContain('Meta: {"done":true}');
|
||||
expect(text).toContain("## Tools");
|
||||
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
|
||||
});
|
||||
|
||||
test("middle steps show meta summary only, not full content", async () => {
|
||||
const cas = createCasStore(casRoot);
|
||||
const ha = await putContentMerkleNode(cas, "HIDDEN_A");
|
||||
const hb = await putContentMerkleNode(cas, "HIDDEN_B_MIDDLE");
|
||||
const hc = await putContentMerkleNode(cas, "VISIBLE_LAST");
|
||||
test("middle steps show meta summary only, not full content", () => {
|
||||
const ctx: ThreadContext = {
|
||||
start: startTask("start"),
|
||||
depth: 0,
|
||||
threadId: "01TEST000000000000000000TR",
|
||||
currentRole: { name: "c", systemPrompt: "S" },
|
||||
cas,
|
||||
steps: [
|
||||
{
|
||||
role: "a",
|
||||
contentHash: ha,
|
||||
content: "HIDDEN_A",
|
||||
meta: { n: 1 },
|
||||
refs: [ha],
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
role: "b",
|
||||
contentHash: hb,
|
||||
content: "HIDDEN_B_MIDDLE",
|
||||
meta: { n: 2 },
|
||||
refs: [hb],
|
||||
timestamp: 3,
|
||||
},
|
||||
{
|
||||
role: "c",
|
||||
contentHash: hc,
|
||||
content: "VISIBLE_LAST",
|
||||
meta: { n: 3 },
|
||||
refs: [hc],
|
||||
timestamp: 4,
|
||||
},
|
||||
],
|
||||
};
|
||||
const text = await buildAgentPrompt(ctx);
|
||||
const text = buildAgentPrompt("S", ctx);
|
||||
expect(text).not.toContain("HIDDEN_A");
|
||||
expect(text).not.toContain("HIDDEN_B_MIDDLE");
|
||||
expect(text).toContain('Summary: {"n":1}');
|
||||
|
||||
@@ -4,12 +4,6 @@
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "echo 'TODO'",
|
||||
"test": "bun test"
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import type { AgentContext } from "@uncaged/workflow";
|
||||
import { getContentMerklePayload } from "@uncaged/workflow";
|
||||
|
||||
async function resolveStepText(ctx: AgentContext, contentHash: string): Promise<string> {
|
||||
const text = await getContentMerklePayload(ctx.cas, contentHash);
|
||||
if (text === null) {
|
||||
throw new Error(`buildAgentPrompt: missing CAS blob for ${contentHash}`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
import type { ThreadContext } from "@uncaged/workflow";
|
||||
|
||||
/** Builds the full agent prompt: system instructions plus summarized thread history. */
|
||||
export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
|
||||
export function buildAgentPrompt(systemPrompt: string, ctx: ThreadContext): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(ctx.currentRole.systemPrompt);
|
||||
lines.push(systemPrompt);
|
||||
lines.push("");
|
||||
lines.push("## Task");
|
||||
lines.push(ctx.start.content);
|
||||
@@ -24,11 +15,10 @@ export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
|
||||
|
||||
if (steps.length === 1) {
|
||||
const s = steps[0];
|
||||
const body = await resolveStepText(ctx, s.contentHash);
|
||||
lines.push("");
|
||||
lines.push(`## Step: ${s.role}`);
|
||||
lines.push("");
|
||||
lines.push(body);
|
||||
lines.push(s.content);
|
||||
lines.push("");
|
||||
lines.push(`Meta: ${JSON.stringify(s.meta)}`);
|
||||
} else {
|
||||
@@ -41,20 +31,17 @@ export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
|
||||
lines.push(`Summary: ${JSON.stringify(s.meta)}`);
|
||||
}
|
||||
const last = steps[steps.length - 1];
|
||||
const lastBody = await resolveStepText(ctx, last.contentHash);
|
||||
lines.push("");
|
||||
lines.push(`## Latest Step: ${last.role}`);
|
||||
lines.push("");
|
||||
lines.push(lastBody);
|
||||
lines.push(last.content);
|
||||
lines.push("");
|
||||
lines.push(`Meta: ${JSON.stringify(last.meta)}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("## Tools");
|
||||
lines.push(
|
||||
`Use \`uncaged-workflow thread ${ctx.threadId}\` to read full details of any previous step.`,
|
||||
);
|
||||
lines.push("Use `uncaged-workflow thread <threadId>` to read full details of any previous step.");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { validateWorkflowDescriptor } from "@uncaged/workflow";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { buildDescriptorFromRoles } from "../src/build-descriptor.js";
|
||||
|
||||
describe("buildDescriptorFromRoles", () => {
|
||||
test("produces a descriptor that validates and includes JSON schemas per role", () => {
|
||||
const schema = z.object({
|
||||
title: z.string(),
|
||||
count: z.number(),
|
||||
});
|
||||
|
||||
const descriptor = buildDescriptorFromRoles({
|
||||
description: "Demo workflow",
|
||||
roles: {
|
||||
analyst: {
|
||||
name: "analyst",
|
||||
schema,
|
||||
description: "Analyzes input",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const validated = validateWorkflowDescriptor(descriptor);
|
||||
expect(validated.ok).toBe(true);
|
||||
if (!validated.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(validated.value.description).toBe("Demo workflow");
|
||||
const analyst = validated.value.roles.analyst;
|
||||
expect(analyst.description).toBe("Analyzes input");
|
||||
expect(analyst.schema.type).toBe("object");
|
||||
const props = analyst.schema.properties as Record<string, unknown>;
|
||||
expect(props.title).toMatchObject({ type: "string" });
|
||||
expect(props.count).toMatchObject({ type: "number" });
|
||||
});
|
||||
|
||||
test("uses empty description when spec.description is null", () => {
|
||||
const descriptor = buildDescriptorFromRoles({
|
||||
description: "W",
|
||||
roles: {
|
||||
x: {
|
||||
name: "x",
|
||||
schema: z.object({ n: z.number() }),
|
||||
description: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const validated = validateWorkflowDescriptor(descriptor);
|
||||
expect(validated.ok).toBe(true);
|
||||
if (!validated.ok) {
|
||||
return;
|
||||
}
|
||||
expect(validated.value.roles.x.description).toBe("");
|
||||
});
|
||||
|
||||
test("throws when role key and spec.name diverge", () => {
|
||||
expect(() =>
|
||||
buildDescriptorFromRoles({
|
||||
description: "W",
|
||||
roles: {
|
||||
a: {
|
||||
name: "b",
|
||||
schema: z.object({ n: z.number() }),
|
||||
description: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow(/must match spec.name/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { Role, ThreadContext } from "@uncaged/workflow";
|
||||
import { START } from "@uncaged/workflow";
|
||||
|
||||
import { decorateRole, onFail, withDryRun } from "../src/decorators.js";
|
||||
|
||||
type TestMeta = Record<string, unknown> & { ok: boolean };
|
||||
|
||||
function fakeCtx(): ThreadContext {
|
||||
return {
|
||||
start: {
|
||||
role: START,
|
||||
content: "",
|
||||
meta: {
|
||||
maxRounds: 10,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
steps: [],
|
||||
};
|
||||
}
|
||||
|
||||
const successRole: Role<TestMeta> = async () => ({
|
||||
content: "done",
|
||||
meta: { ok: true },
|
||||
});
|
||||
|
||||
const failRole: Role<TestMeta> = async () => {
|
||||
throw new Error("boom");
|
||||
};
|
||||
|
||||
const failNonErrorRole: Role<TestMeta> = async () => {
|
||||
throw "string error";
|
||||
};
|
||||
|
||||
describe("withDryRun", () => {
|
||||
test("short-circuits on dry-run", async () => {
|
||||
const dec = withDryRun<TestMeta>({ label: "test", meta: { ok: true }, dryRun: true });
|
||||
const role = dec(successRole);
|
||||
const result = await role(fakeCtx());
|
||||
expect(result.content).toBe("[dry-run] test skipped");
|
||||
expect(result.meta).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test("delegates when not dry-run", async () => {
|
||||
const innerDec = withDryRun<TestMeta>({ label: "test", meta: { ok: true }, dryRun: false });
|
||||
const role = innerDec(successRole);
|
||||
const result = await role(fakeCtx());
|
||||
expect(result.content).toBe("done");
|
||||
expect(result.meta).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("onFail", () => {
|
||||
test("passes through on success", async () => {
|
||||
const dec = onFail<TestMeta>({ label: "test", meta: { ok: false } });
|
||||
const role = dec(successRole);
|
||||
const result = await role(fakeCtx());
|
||||
expect(result.content).toBe("done");
|
||||
expect(result.meta).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test("catches Error and returns structured failure", async () => {
|
||||
const dec = onFail<TestMeta>({ label: "test", meta: { ok: false } });
|
||||
const role = dec(failRole);
|
||||
const result = await role(fakeCtx());
|
||||
expect(result.content).toBe("test failed: boom");
|
||||
expect(result.meta).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
test("catches non-Error throws", async () => {
|
||||
const dec = onFail<TestMeta>({ label: "test", meta: { ok: false } });
|
||||
const role = dec(failNonErrorRole);
|
||||
const result = await role(fakeCtx());
|
||||
expect(result.content).toBe("test failed: string error");
|
||||
expect(result.meta).toEqual({ ok: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("decorateRole", () => {
|
||||
test("applies decorators left-to-right", async () => {
|
||||
const role = decorateRole(failRole, [
|
||||
withDryRun<TestMeta>({ label: "x", meta: { ok: true }, dryRun: false }),
|
||||
onFail<TestMeta>({ label: "x", meta: { ok: false } }),
|
||||
]);
|
||||
const result = await role(fakeCtx());
|
||||
expect(result.content).toBe("x failed: boom");
|
||||
expect(result.meta).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
test("dry-run short-circuits before onFail", async () => {
|
||||
const role = decorateRole(failRole, [
|
||||
withDryRun<TestMeta>({ label: "x", meta: { ok: true }, dryRun: true }),
|
||||
onFail<TestMeta>({ label: "x", meta: { ok: false } }),
|
||||
]);
|
||||
const result = await role(fakeCtx());
|
||||
expect(result.content).toBe("[dry-run] x skipped");
|
||||
expect(result.meta).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { extractMetaOrThrow } from "../src/extract-meta.js";
|
||||
|
||||
const provider = {
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: "k",
|
||||
model: "m",
|
||||
};
|
||||
|
||||
describe("extractMetaOrThrow", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test("dryRun returns dryRunMeta without calling fetch", async () => {
|
||||
let calls = 0;
|
||||
globalThis.fetch = (() => {
|
||||
calls += 1;
|
||||
return Promise.resolve(new Response("{}", { status: 200 }));
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const schema = z.object({ n: z.number() });
|
||||
const out = await extractMetaOrThrow("r", "raw", schema, {
|
||||
provider,
|
||||
dryRun: true,
|
||||
dryRunMeta: { n: 7 },
|
||||
});
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
expect(calls).toBe(0);
|
||||
expect(out).toEqual({ n: 7 });
|
||||
});
|
||||
|
||||
test("throws when extraction fails after retry", async () => {
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{ function: { name: "extract", arguments: JSON.stringify({ n: "bad" }) } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
)) as unknown as typeof fetch;
|
||||
|
||||
const schema = z.object({ n: z.number() });
|
||||
|
||||
await expect(
|
||||
extractMetaOrThrow("plan", "text", schema, { provider, dryRun: false, dryRunMeta: { n: 0 } }),
|
||||
).rejects.toThrow(/structured extraction failed after retry/);
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("returns validated meta on successful tool call", async () => {
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: "extract",
|
||||
arguments: JSON.stringify({ branch: "feat/x", message: "feat: y" }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
)) as unknown as typeof fetch;
|
||||
|
||||
const schema = z.object({
|
||||
branch: z.string(),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
const out = await extractMetaOrThrow("committer-plan", "plan text", schema, {
|
||||
provider,
|
||||
dryRun: false,
|
||||
dryRunMeta: { branch: "", message: "" },
|
||||
});
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
expect(out).toEqual({ branch: "feat/x", message: "feat: y" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { llmExtract } from "../src/llm-extract.js";
|
||||
|
||||
describe("llmExtract", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test("parses tool call arguments and validates with the zod schema", async () => {
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
})
|
||||
.describe("Extract sense metadata from plan");
|
||||
|
||||
let capturedUrl = "";
|
||||
let capturedInit: RequestInit = {};
|
||||
|
||||
globalThis.fetch = ((input: Request | string | URL, init?: RequestInit) => {
|
||||
capturedUrl = typeof input === "string" ? input : input.toString();
|
||||
capturedInit = init ?? {};
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
function: {
|
||||
name: "extract",
|
||||
arguments: JSON.stringify({
|
||||
name: "cpu-usage",
|
||||
description: "CPU load",
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = await llmExtract({
|
||||
text: "some plan",
|
||||
schema,
|
||||
provider: {
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: "k",
|
||||
model: "m",
|
||||
},
|
||||
dryRun: false,
|
||||
dryRunMeta: { name: "", description: "" },
|
||||
});
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.value).toEqual({ name: "cpu-usage", description: "CPU load" });
|
||||
|
||||
expect(capturedUrl).toBe("https://example.com/v1/chat/completions");
|
||||
expect(capturedInit.method).toBe("POST");
|
||||
expect(capturedInit.headers).toMatchObject({
|
||||
Authorization: "Bearer k",
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
const body = JSON.parse(capturedInit.body as string) as {
|
||||
model: string;
|
||||
tool_choice: { function: { name: string } };
|
||||
};
|
||||
expect(body.model).toBe("m");
|
||||
expect(body.tool_choice.function.name).toBeDefined();
|
||||
});
|
||||
|
||||
test("returns schema_validation_failed when arguments do not match the schema", async () => {
|
||||
const schema = z.object({ n: z.number() });
|
||||
|
||||
globalThis.fetch = (() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{ function: { name: "extract", arguments: JSON.stringify({ n: "oops" }) } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
)) as unknown as typeof fetch;
|
||||
|
||||
const result = await llmExtract({
|
||||
text: "x",
|
||||
schema,
|
||||
provider: { baseUrl: "https://example.com", apiKey: "k", model: "m" },
|
||||
dryRun: false,
|
||||
dryRunMeta: { n: 0 },
|
||||
});
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.error.kind).toBe("schema_validation_failed");
|
||||
});
|
||||
|
||||
test("dryRun skips fetch and returns dryRunMeta", async () => {
|
||||
let calls = 0;
|
||||
globalThis.fetch = (() => {
|
||||
calls += 1;
|
||||
return Promise.resolve(new Response("{}", { status: 200 }));
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const schema = z.object({ n: z.number() });
|
||||
const result = await llmExtract({
|
||||
text: "ignored",
|
||||
schema,
|
||||
provider: { baseUrl: "https://example.com", apiKey: "k", model: "m" },
|
||||
dryRun: true,
|
||||
dryRunMeta: { n: 42 },
|
||||
});
|
||||
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
expect(calls).toBe(0);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.value).toEqual({ n: 42 });
|
||||
});
|
||||
});
|
||||
+5
-2
@@ -1,12 +1,15 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-role-planner",
|
||||
"name": "@uncaged/workflow-util-role",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "echo 'TODO'",
|
||||
"test": "echo no tests"
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { WorkflowDescriptor, WorkflowRoleSchema } from "@uncaged/workflow";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export type RoleDescriptorInput<M extends Record<string, unknown> = Record<string, unknown>> = {
|
||||
name: string;
|
||||
schema: z.ZodType<M>;
|
||||
/** Human-readable role description; use empty string when unknown. */
|
||||
description: string | null;
|
||||
};
|
||||
|
||||
function stripJsonSchemaMeta(json: Record<string, unknown>): WorkflowRoleSchema {
|
||||
const { $schema: _drop, ...rest } = json;
|
||||
return rest as WorkflowRoleSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a {@link WorkflowDescriptor} from role specs, emitting JSON Schema per role via
|
||||
* `z.toJSONSchema`.
|
||||
*/
|
||||
export function buildDescriptorFromRoles(args: {
|
||||
description: string;
|
||||
roles: Record<string, RoleDescriptorInput>;
|
||||
}): WorkflowDescriptor {
|
||||
const roles: WorkflowDescriptor["roles"] = {};
|
||||
for (const [key, spec] of Object.entries(args.roles)) {
|
||||
if (spec.name !== key) {
|
||||
throw new Error(
|
||||
`buildDescriptorFromRoles: role key "${key}" must match spec.name "${spec.name}"`,
|
||||
);
|
||||
}
|
||||
const rawJsonSchema = z.toJSONSchema(spec.schema) as Record<string, unknown>;
|
||||
roles[key] = {
|
||||
description: spec.description === null ? "" : spec.description,
|
||||
schema: stripJsonSchemaMeta(rawJsonSchema),
|
||||
};
|
||||
}
|
||||
return { description: args.description, roles };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Role, ThreadContext } from "@uncaged/workflow";
|
||||
|
||||
/** A role decorator: takes a role, returns an enhanced role. */
|
||||
export type RoleDecorator<M extends Record<string, unknown>> = (role: Role<M>) => Role<M>;
|
||||
|
||||
/**
|
||||
* Apply an ordered list of decorators to a role.
|
||||
* Decorators are applied left-to-right (first in list wraps innermost).
|
||||
*/
|
||||
export function decorateRole<M extends Record<string, unknown>>(
|
||||
role: Role<M>,
|
||||
decorators: RoleDecorator<M>[],
|
||||
): Role<M> {
|
||||
return decorators.reduce((r, dec) => dec(r), role);
|
||||
}
|
||||
|
||||
export type WithDryRunOptions<M extends Record<string, unknown>> = {
|
||||
/** Used in skip message (e.g. "committer", "publish"). */
|
||||
label: string;
|
||||
/** Meta returned when dry-run skips execution. */
|
||||
meta: M;
|
||||
/** Adapter-level dry-run flag (e.g. from extract / wiring config). */
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
/** Short-circuits with a stable result when `dryRun` is true. */
|
||||
export function withDryRun<M extends Record<string, unknown>>(
|
||||
opts: WithDryRunOptions<M>,
|
||||
): RoleDecorator<M> {
|
||||
return (role) => async (ctx: ThreadContext) => {
|
||||
if (opts.dryRun) {
|
||||
return {
|
||||
content: `[dry-run] ${opts.label} skipped`,
|
||||
meta: opts.meta,
|
||||
};
|
||||
}
|
||||
return role(ctx);
|
||||
};
|
||||
}
|
||||
|
||||
export type OnFailOptions<M extends Record<string, unknown>> = {
|
||||
/** Used in failure message (e.g. "committer", "publish"). */
|
||||
label: string;
|
||||
/** Meta returned when the inner role throws. */
|
||||
meta: M;
|
||||
};
|
||||
|
||||
/** Catches thrown errors and converts them into a structured {@link Role} result instead of propagating. */
|
||||
export function onFail<M extends Record<string, unknown>>(
|
||||
opts: OnFailOptions<M>,
|
||||
): RoleDecorator<M> {
|
||||
return (role) => async (ctx: ThreadContext) => {
|
||||
try {
|
||||
return await role(ctx);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return {
|
||||
content: `${opts.label} failed: ${msg}`,
|
||||
meta: opts.meta,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type * as z from "zod/v4";
|
||||
import { llmExtractWithRetry } from "./llm-extract.js";
|
||||
import type { LlmProvider } from "./types.js";
|
||||
|
||||
export async function extractMetaOrThrow<T extends Record<string, unknown>>(
|
||||
roleName: string,
|
||||
raw: string,
|
||||
schema: z.ZodType<T>,
|
||||
options: { provider: LlmProvider; dryRun: boolean; dryRunMeta: T },
|
||||
): Promise<T> {
|
||||
const result = await llmExtractWithRetry({
|
||||
text: raw,
|
||||
schema,
|
||||
provider: options.provider,
|
||||
dryRun: options.dryRun,
|
||||
dryRunMeta: options.dryRunMeta,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(
|
||||
`Role "${roleName}": structured extraction failed after retry: ${JSON.stringify(result.error)}`,
|
||||
);
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export { buildDescriptorFromRoles, type RoleDescriptorInput } from "./build-descriptor.js";
|
||||
export {
|
||||
decorateRole,
|
||||
type OnFailOptions,
|
||||
onFail,
|
||||
type RoleDecorator,
|
||||
type WithDryRunOptions,
|
||||
withDryRun,
|
||||
} from "./decorators.js";
|
||||
export { extractMetaOrThrow } from "./extract-meta.js";
|
||||
export {
|
||||
type LlmError,
|
||||
type LlmExtractArgs,
|
||||
llmErrorToCause,
|
||||
llmExtract,
|
||||
llmExtractWithRetry,
|
||||
} from "./llm-extract.js";
|
||||
export type { LlmMessage, LlmProvider, MetaExtractConfig } from "./types.js";
|
||||
+16
-22
@@ -1,12 +1,14 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { err, ok, type Result } from "./result.js";
|
||||
import type { LlmProvider } from "./types.js";
|
||||
|
||||
export type LlmExtractArgs<T> = {
|
||||
text: string;
|
||||
schema: z.ZodType<T>;
|
||||
provider: LlmProvider;
|
||||
dryRun: boolean;
|
||||
/** Returned when `dryRun` is true (ignored for live extract). */
|
||||
dryRunMeta: T;
|
||||
};
|
||||
|
||||
export type LlmError =
|
||||
@@ -47,21 +49,6 @@ function readToolDescription(parametersSchema: Record<string, unknown>): string
|
||||
return "Extract structured data from the input text.";
|
||||
}
|
||||
|
||||
/** Builds OpenAI function-tool metadata from a Zod meta schema (same naming rules as single-shot extract). */
|
||||
export function extractFunctionToolFromZodSchema(schema: z.ZodType<unknown>): {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
} {
|
||||
const rawJsonSchema = z.toJSONSchema(schema) as Record<string, unknown>;
|
||||
const parameters = stripJsonSchemaMeta(rawJsonSchema);
|
||||
return {
|
||||
name: readToolName(parameters),
|
||||
description: readToolDescription(parameters),
|
||||
parameters,
|
||||
};
|
||||
}
|
||||
|
||||
function readToolArgumentsJson(parsed: unknown, previewSource: string): Result<string, LlmError> {
|
||||
if (!isRecord(parsed)) {
|
||||
return err({ kind: "invalid_response_json", message: "Top-level JSON is not an object" });
|
||||
@@ -139,7 +126,14 @@ export function llmErrorToCause(error: LlmError): Error {
|
||||
async function performLlmExtract<T>(
|
||||
options: LlmExtractArgs<T> & { userContent: string },
|
||||
): Promise<Result<T, LlmError>> {
|
||||
const extractTool = extractFunctionToolFromZodSchema(options.schema);
|
||||
if (options.dryRun) {
|
||||
return ok(options.dryRunMeta);
|
||||
}
|
||||
|
||||
const rawJsonSchema = z.toJSONSchema(options.schema) as Record<string, unknown>;
|
||||
const parameters = stripJsonSchemaMeta(rawJsonSchema);
|
||||
const toolName = readToolName(parameters);
|
||||
const toolDescription = readToolDescription(parameters);
|
||||
|
||||
const body = {
|
||||
model: options.provider.model,
|
||||
@@ -154,13 +148,13 @@ async function performLlmExtract<T>(
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: extractTool.name,
|
||||
description: extractTool.description,
|
||||
parameters: extractTool.parameters,
|
||||
name: toolName,
|
||||
description: toolDescription,
|
||||
parameters,
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_choice: { type: "function" as const, function: { name: extractTool.name } },
|
||||
tool_choice: { type: "function" as const, function: { name: toolName } },
|
||||
};
|
||||
|
||||
let response: Response;
|
||||
@@ -0,0 +1,15 @@
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
export type LlmProvider = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
||||
|
||||
/** Pairs an OpenAI-compatible provider with the Zod meta schema used for structured extraction. */
|
||||
export type MetaExtractConfig<T> = {
|
||||
provider: LlmProvider;
|
||||
schema: z.ZodType<T>;
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { buildDescriptor } from "../src/build-descriptor.js";
|
||||
import { END } from "../src/types.js";
|
||||
import { validateWorkflowDescriptor } from "../src/workflow-descriptor.js";
|
||||
|
||||
describe("buildDescriptor", () => {
|
||||
test("produces a descriptor that validates and includes JSON schemas per role", () => {
|
||||
const schema = z.object({
|
||||
title: z.string(),
|
||||
count: z.number(),
|
||||
});
|
||||
|
||||
type M = { analyst: z.infer<typeof schema> };
|
||||
|
||||
const descriptor = buildDescriptor<M>({
|
||||
description: "Demo workflow",
|
||||
roles: {
|
||||
analyst: {
|
||||
description: "Analyzes input",
|
||||
systemPrompt: "You are an analyst.",
|
||||
extractPrompt: "Extract title and count from the analysis.",
|
||||
schema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
},
|
||||
},
|
||||
moderator: () => END,
|
||||
});
|
||||
|
||||
const validated = validateWorkflowDescriptor(descriptor);
|
||||
expect(validated.ok).toBe(true);
|
||||
if (!validated.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(validated.value.description).toBe("Demo workflow");
|
||||
const analyst = validated.value.roles.analyst;
|
||||
expect(analyst.description).toBe("Analyzes input");
|
||||
expect(analyst.schema.type).toBe("object");
|
||||
const props = analyst.schema.properties as Record<string, unknown>;
|
||||
expect(props.title).toMatchObject({ type: "string" });
|
||||
expect(props.count).toMatchObject({ type: "number" });
|
||||
});
|
||||
});
|
||||
@@ -26,19 +26,6 @@ export const run = async function* (input) {
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("allows static import of @uncaged/workflow", () => {
|
||||
const source = `${minimalDescriptor}import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
|
||||
export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
return { returnCode: 0, summary: h };
|
||||
};
|
||||
`;
|
||||
const r = validateWorkflowBundle({ filePath: "/tmp/w.esm.js", source });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects wrong filename suffix", () => {
|
||||
const r = validateWorkflowBundle({
|
||||
filePath: "/tmp/w.js",
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createCasStore, createThreadCas } from "../src/cas.js";
|
||||
import { hashString } from "../src/hash.js";
|
||||
|
||||
describe("cas module exports", () => {
|
||||
test("createThreadCas is a deprecated alias of createCasStore", () => {
|
||||
expect(createThreadCas).toBe(createCasStore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCasStore", () => {
|
||||
let casDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
casDir = await mkdtemp(join(tmpdir(), "cas-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(casDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("put returns consistent hash for same content", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const h1 = await cas.put("hello world");
|
||||
const h2 = await cas.put("hello world");
|
||||
expect(h1).toBe(h2);
|
||||
expect(h1).toHaveLength(13);
|
||||
});
|
||||
|
||||
test("put returns hash matching hashString", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const content = "some content to store";
|
||||
const h = await cas.put(content);
|
||||
expect(h).toBe(hashString(content));
|
||||
});
|
||||
|
||||
test("get returns stored content", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const content = "line1\nline2\nline3";
|
||||
const h = await cas.put(content);
|
||||
const retrieved = await cas.get(h);
|
||||
expect(retrieved).toBe(content);
|
||||
});
|
||||
|
||||
test("get returns null for missing hash", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const result = await cas.get("0000000000000");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("delete removes entry", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const h = await cas.put("to be deleted");
|
||||
await cas.delete(h);
|
||||
const result = await cas.get(h);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("delete on missing hash does not throw", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
await cas.delete("0000000000000");
|
||||
});
|
||||
|
||||
test("list returns all stored hashes", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const h1 = await cas.put("aaa");
|
||||
const h2 = await cas.put("bbb");
|
||||
const h3 = await cas.put("ccc");
|
||||
const hashes = await cas.list();
|
||||
expect(hashes.sort()).toEqual([h1, h2, h3].sort());
|
||||
});
|
||||
|
||||
test("list returns empty array when cas dir does not exist", async () => {
|
||||
const cas = createCasStore(join(casDir, "nonexistent"));
|
||||
const hashes = await cas.list();
|
||||
expect(hashes).toEqual([]);
|
||||
});
|
||||
|
||||
test("put is idempotent — same content written twice causes no error", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const h1 = await cas.put("idempotent");
|
||||
const h2 = await cas.put("idempotent");
|
||||
expect(h1).toBe(h2);
|
||||
const content = await cas.get(h1);
|
||||
expect(content).toBe("idempotent");
|
||||
});
|
||||
|
||||
test("different content produces different hashes", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const h1 = await cas.put("alpha");
|
||||
const h2 = await cas.put("beta");
|
||||
expect(h1).not.toBe(h2);
|
||||
});
|
||||
});
|
||||
@@ -1,148 +1,42 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createCasStore } from "../src/cas.js";
|
||||
import { createWorkflow } from "../src/create-workflow.js";
|
||||
import { createRoleModerator } from "../src/create-role-moderator.js";
|
||||
import { executeThread } from "../src/engine.js";
|
||||
import { createExtract } from "../src/extract-fn.js";
|
||||
import { createLogger } from "../src/logger.js";
|
||||
import {
|
||||
createContentMerkleNode,
|
||||
getContentMerklePayload,
|
||||
parseMerkleNode,
|
||||
serializeMerkleNode,
|
||||
} from "../src/merkle.js";
|
||||
import { END, type LlmProvider } from "../src/types.js";
|
||||
|
||||
const plannerMetaSchema = z.object({
|
||||
plan: z.string(),
|
||||
files: z.array(z.string()),
|
||||
});
|
||||
|
||||
const coderMetaSchema = z.object({
|
||||
diff: z.string(),
|
||||
});
|
||||
import { END } from "../src/types.js";
|
||||
|
||||
type DemoMeta = {
|
||||
planner: z.infer<typeof plannerMetaSchema>;
|
||||
coder: z.infer<typeof coderMetaSchema>;
|
||||
planner: Record<string, unknown>;
|
||||
coder: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unknown>>): () => void {
|
||||
const origFetch = globalThis.fetch;
|
||||
let i = 0;
|
||||
const mockFetch = async (
|
||||
input: Parameters<typeof fetch>[0],
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||
if (args === undefined) {
|
||||
throw new Error("installMockChatCompletions: empty sequence");
|
||||
const demoWorkflow = createRoleModerator<DemoMeta>({
|
||||
roles: {
|
||||
planner: async () => ({
|
||||
content: "plan-body",
|
||||
meta: { plan: "do-it", files: ["a.ts"] },
|
||||
}),
|
||||
coder: async () => ({
|
||||
content: "code-body",
|
||||
meta: { diff: "+ok" },
|
||||
}),
|
||||
},
|
||||
moderator: (ctx) => {
|
||||
if (ctx.steps.length === 0) {
|
||||
return "planner";
|
||||
}
|
||||
i += 1;
|
||||
void input;
|
||||
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
|
||||
const tools = body.tools;
|
||||
const firstTool =
|
||||
Array.isArray(tools) && tools.length > 0 && tools[0] !== null && typeof tools[0] === "object"
|
||||
? (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, {
|
||||
preconnect: origFetch.preconnect.bind(origFetch),
|
||||
}) as typeof fetch;
|
||||
return () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
}
|
||||
|
||||
const demoExtract = createExtract({
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "test",
|
||||
model: "test",
|
||||
if (ctx.steps.length === 1) {
|
||||
return "coder";
|
||||
}
|
||||
return END;
|
||||
},
|
||||
});
|
||||
|
||||
const demoWorkflow = createWorkflow<DemoMeta>(
|
||||
{
|
||||
roles: {
|
||||
planner: {
|
||||
description: "Demo planner",
|
||||
systemPrompt: "You are a planner.",
|
||||
extractPrompt: "Extract plan text and affected files list.",
|
||||
schema: plannerMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
},
|
||||
coder: {
|
||||
description: "Demo coder",
|
||||
systemPrompt: "You are a coder.",
|
||||
extractPrompt: "Extract the code diff summary.",
|
||||
schema: coderMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
},
|
||||
},
|
||||
moderator: (ctx) => {
|
||||
if (ctx.steps.length === 0) {
|
||||
return "planner";
|
||||
}
|
||||
if (ctx.steps.length === 1) {
|
||||
return "coder";
|
||||
}
|
||||
return END;
|
||||
},
|
||||
},
|
||||
{
|
||||
agent: async () => "unused",
|
||||
overrides: {
|
||||
planner: async () => "plan-body",
|
||||
coder: async () => "code-body",
|
||||
},
|
||||
},
|
||||
demoExtract,
|
||||
null,
|
||||
);
|
||||
|
||||
describe("executeThread", () => {
|
||||
let restoreFetch: (() => void) | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch?.();
|
||||
restoreFetch = null;
|
||||
});
|
||||
|
||||
test("writes RFC-001 `.data.jsonl` start + role records and `.info.jsonl` logs", async () => {
|
||||
restoreFetch = installMockChatCompletions([
|
||||
{ plan: "do-it", files: ["a.ts"] },
|
||||
{ diff: "+ok" },
|
||||
]);
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-engine-"));
|
||||
try {
|
||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||
@@ -150,7 +44,6 @@ describe("executeThread", () => {
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
@@ -160,31 +53,18 @@ describe("executeThread", () => {
|
||||
"demo-flow",
|
||||
{ prompt: "Fix the login redirect bug in #3", steps: [] },
|
||||
{
|
||||
isDryRun: false,
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
signal: ac.signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath },
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
expect(typeof result.rootHash).toBe("string");
|
||||
expect(result.rootHash.length).toBeGreaterThan(0);
|
||||
|
||||
const rootYaml = await cas.get(result.rootHash);
|
||||
expect(rootYaml).not.toBeNull();
|
||||
const rootNode = parseMerkleNode(rootYaml ?? "");
|
||||
expect(rootNode.type).toBe("thread");
|
||||
const rootPayload = rootNode.payload as Record<string, unknown>;
|
||||
expect(rootPayload.workflow).toBe("demo-flow");
|
||||
expect(rootPayload.threadId).toBe(threadId);
|
||||
const rootResult = rootPayload.result as Record<string, unknown>;
|
||||
expect(rootResult.returnCode).toBe(0);
|
||||
expect(rootNode.children.length).toBe(2);
|
||||
|
||||
const dataText = await readFile(dataPath, "utf8");
|
||||
const lines = dataText
|
||||
@@ -202,35 +82,17 @@ describe("executeThread", () => {
|
||||
const params = start.parameters as Record<string, unknown>;
|
||||
expect(params.prompt).toBe("Fix the login redirect bug in #3");
|
||||
const opts = params.options as Record<string, unknown>;
|
||||
expect(opts.isDryRun).toBe(false);
|
||||
expect(opts.maxRounds).toBe(5);
|
||||
expect(opts.depth).toBe(0);
|
||||
expect(Object.keys(opts).sort()).toEqual(["depth", "maxRounds"]);
|
||||
|
||||
const role1 = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
||||
expect(role1.role).toBe("planner");
|
||||
expect(typeof role1.contentHash).toBe("string");
|
||||
expect(await getContentMerklePayload(cas, String(role1.contentHash))).toBe("plan-body");
|
||||
expect(role1.content).toBe("plan-body");
|
||||
expect(role1.meta).toEqual({ plan: "do-it", files: ["a.ts"] });
|
||||
expect(role1.refs).toEqual([role1.contentHash]);
|
||||
expect(typeof role1.timestamp).toBe("number");
|
||||
|
||||
const role2 = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||
expect(role2.role).toBe("coder");
|
||||
expect(role2.refs).toEqual([role2.contentHash]);
|
||||
|
||||
const step1Yaml = await cas.get(rootNode.children[0] ?? "");
|
||||
const step2Yaml = await cas.get(rootNode.children[1] ?? "");
|
||||
expect(step1Yaml).not.toBeNull();
|
||||
expect(step2Yaml).not.toBeNull();
|
||||
const step1Node = parseMerkleNode(step1Yaml ?? "");
|
||||
const step2Node = parseMerkleNode(step2Yaml ?? "");
|
||||
expect(step1Node.type).toBe("step");
|
||||
expect(step2Node.type).toBe("step");
|
||||
expect(step1Node.children).toEqual([String(role1.contentHash)]);
|
||||
expect(step2Node.children).toEqual([String(role2.contentHash)]);
|
||||
const step1Payload = step1Node.payload as Record<string, unknown>;
|
||||
expect(step1Payload.role).toBe("planner");
|
||||
expect(step1Payload.meta).toEqual({ plan: "do-it", files: ["a.ts"] });
|
||||
|
||||
const infoText = await readFile(infoPath, "utf8");
|
||||
const infoLines = infoText
|
||||
@@ -249,8 +111,6 @@ describe("executeThread", () => {
|
||||
});
|
||||
|
||||
test("pre-filled ThreadInput.steps skips roles already present", async () => {
|
||||
restoreFetch = installMockChatCompletions([{ diff: "+ok" }]);
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-engine-fork-"));
|
||||
try {
|
||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||
@@ -258,14 +118,11 @@ describe("executeThread", () => {
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
const plannerHash = await cas.put(serializeMerkleNode(createContentMerkleNode("plan-body")));
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
|
||||
const histTs = 9_000_000;
|
||||
const mergedPlannerRefs = ["CAS111AAAAAAA", plannerHash];
|
||||
const result = await executeThread(
|
||||
demoWorkflow,
|
||||
"demo-flow",
|
||||
@@ -274,38 +131,31 @@ describe("executeThread", () => {
|
||||
steps: [
|
||||
{
|
||||
role: "planner",
|
||||
contentHash: plannerHash,
|
||||
content: "plan-body",
|
||||
meta: { plan: "do-it", files: ["a.ts"] },
|
||||
refs: mergedPlannerRefs,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
isDryRun: false,
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
signal: ac.signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: "01SRC1111111111111111111",
|
||||
prefilledDiskSteps: [
|
||||
{
|
||||
role: "planner",
|
||||
contentHash: plannerHash,
|
||||
content: "plan-body",
|
||||
meta: { plan: "do-it", files: ["a.ts"] },
|
||||
refs: mergedPlannerRefs,
|
||||
timestamp: histTs,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath },
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
expect(typeof result.rootHash).toBe("string");
|
||||
|
||||
const rootYaml = await cas.get(result.rootHash);
|
||||
const rootNode = parseMerkleNode(rootYaml ?? "");
|
||||
expect(rootNode.children.length).toBe(2);
|
||||
|
||||
const dataText = await readFile(dataPath, "utf8");
|
||||
const lines = dataText
|
||||
@@ -320,11 +170,10 @@ describe("executeThread", () => {
|
||||
const role0 = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
||||
expect(role0.role).toBe("planner");
|
||||
expect(role0.timestamp).toBe(histTs);
|
||||
expect(role0.refs).toEqual(mergedPlannerRefs);
|
||||
|
||||
const role1 = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||
expect(role1.role).toBe("coder");
|
||||
expect(await getContentMerklePayload(cas, String(role1.contentHash))).toBe("code-body");
|
||||
expect(role1.content).toBe("code-body");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -338,7 +187,6 @@ describe("executeThread", () => {
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
@@ -348,24 +196,18 @@ describe("executeThread", () => {
|
||||
"demo-flow",
|
||||
{ prompt: "hello", steps: [] },
|
||||
{
|
||||
isDryRun: false,
|
||||
maxRounds: 0,
|
||||
depth: 0,
|
||||
signal: ac.signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath },
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
expect(typeof result.rootHash).toBe("string");
|
||||
|
||||
const rootYaml = await cas.get(result.rootHash);
|
||||
const rootNode = parseMerkleNode(rootYaml ?? "");
|
||||
expect(rootNode.type).toBe("thread");
|
||||
expect(rootNode.children.length).toBe(0);
|
||||
|
||||
const dataText = await readFile(dataPath, "utf8");
|
||||
const lines = dataText
|
||||
@@ -377,240 +219,4 @@ describe("executeThread", () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Merkle DAG: root → step nodes → content for full thread traversal", async () => {
|
||||
restoreFetch = installMockChatCompletions([
|
||||
{ plan: "do-it", files: ["a.ts"] },
|
||||
{ diff: "+ok" },
|
||||
]);
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-engine-dag-"));
|
||||
try {
|
||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||
const hash = "C9NMV6V2TQT81";
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
|
||||
const result = await executeThread(
|
||||
demoWorkflow,
|
||||
"demo-flow",
|
||||
{ prompt: "DAG test", steps: [] },
|
||||
{
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
signal: ac.signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
);
|
||||
|
||||
const dataText = await readFile(dataPath, "utf8");
|
||||
const lines = dataText
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(lines.length).toBe(3);
|
||||
|
||||
const rolePlanner = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
||||
const roleCoder = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||
|
||||
const threadYaml = await cas.get(result.rootHash);
|
||||
expect(threadYaml).not.toBeNull();
|
||||
const threadNode = parseMerkleNode(threadYaml ?? "");
|
||||
expect(threadNode.type).toBe("thread");
|
||||
|
||||
const bodies: string[] = [];
|
||||
for (const stepHash of threadNode.children) {
|
||||
const stepYaml = await cas.get(stepHash);
|
||||
expect(stepYaml).not.toBeNull();
|
||||
const stepNode = parseMerkleNode(stepYaml ?? "");
|
||||
expect(stepNode.type).toBe("step");
|
||||
expect(stepNode.children.length).toBe(1);
|
||||
const contentHash = stepNode.children[0];
|
||||
expect(contentHash).toBeDefined();
|
||||
const body = await getContentMerklePayload(cas, contentHash ?? "");
|
||||
expect(body).not.toBeNull();
|
||||
bodies.push(body ?? "");
|
||||
}
|
||||
|
||||
expect(bodies.sort()).toEqual(["code-body", "plan-body"].sort());
|
||||
expect(rolePlanner.role).toBe("planner");
|
||||
expect(roleCoder.role).toBe("coder");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("extractMode react traverses CAS DAG via cas_get during extraction", async () => {
|
||||
const dagMetaSchema = z.object({ leafPayload: z.string() });
|
||||
type DagDemoMeta = { walker: z.infer<typeof dagMetaSchema> };
|
||||
|
||||
const origFetch = globalThis.fetch;
|
||||
restoreFetch = () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
let fetchRound = 0;
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-engine-react-"));
|
||||
try {
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
const leafYaml = serializeMerkleNode(createContentMerkleNode("needle-from-leaf"));
|
||||
const leafHash = await cas.put(leafYaml);
|
||||
const rootYaml = serializeMerkleNode({
|
||||
type: "thread",
|
||||
payload: {
|
||||
workflow: "dag-demo",
|
||||
threadId: "01DAG00000000000000000001",
|
||||
result: { returnCode: 0, summary: "" },
|
||||
},
|
||||
children: [leafHash],
|
||||
});
|
||||
const dagRootHash = await cas.put(rootYaml);
|
||||
|
||||
globalThis.fetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
||||
fetchRound += 1;
|
||||
if (fetchRound === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "c1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "cas_get",
|
||||
arguments: JSON.stringify({ hash: dagRootHash }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (fetchRound === 2) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "c2",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "cas_get",
|
||||
arguments: JSON.stringify({ hash: leafHash }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "c3",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "extract",
|
||||
arguments: JSON.stringify({ leafPayload: "needle-from-leaf" }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
},
|
||||
{ preconnect: origFetch.preconnect.bind(origFetch) },
|
||||
) as typeof fetch;
|
||||
|
||||
const llm: LlmProvider = { baseUrl: "http://127.0.0.1:9", apiKey: "test", model: "test" };
|
||||
const extractFn = createExtract(llm);
|
||||
|
||||
const dagWorkflow = createWorkflow<DagDemoMeta>(
|
||||
{
|
||||
roles: {
|
||||
walker: {
|
||||
description: "DAG walker",
|
||||
systemPrompt: "Output only the root CAS hash.",
|
||||
extractPrompt:
|
||||
"Set leafPayload to the string payload of the content Merkle node under the root.",
|
||||
schema: dagMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "react",
|
||||
},
|
||||
},
|
||||
moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END),
|
||||
},
|
||||
{ agent: async () => dagRootHash },
|
||||
extractFn,
|
||||
llm,
|
||||
);
|
||||
|
||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||
const hash = "C9NMV6V2TQT81";
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
|
||||
const result = await executeThread(
|
||||
dagWorkflow,
|
||||
"dag-demo",
|
||||
{ prompt: "traverse", steps: [] },
|
||||
{
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
signal: ac.signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
expect(fetchRound).toBe(3);
|
||||
|
||||
const dataText = await readFile(dataPath, "utf8");
|
||||
const lines = dataText
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
const roleRec = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
||||
expect(roleRec.role).toBe("walker");
|
||||
expect(roleRec.meta).toEqual({ leafPayload: "needle-from-leaf" });
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { getExtractProvider } from "../src/extract-provider.js";
|
||||
|
||||
describe("getExtractProvider", () => {
|
||||
test("returns provider when config.extract is present", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-ok-"));
|
||||
try {
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "workflow.yaml"),
|
||||
`config:
|
||||
maxDepth: 3
|
||||
extract:
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
model: qwen-plus
|
||||
apiKey: literal-key
|
||||
workflows: {}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const r = await getExtractProvider(root);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.baseUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
|
||||
expect(r.value.model).toBe("qwen-plus");
|
||||
expect(r.value.apiKey).toBe("literal-key");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("errs when registry has no config section", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-missing-"));
|
||||
try {
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(join(root, "workflow.yaml"), "workflows: {}\n", "utf8");
|
||||
const r = await getExtractProvider(root);
|
||||
expect(r.ok).toBe(false);
|
||||
if (r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.error).toContain("no global config");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves apiKey from env at registry read time", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-env-"));
|
||||
const prev = process.env.WF_GET_EXTRACT_PROVIDER_KEY;
|
||||
process.env.WF_GET_EXTRACT_PROVIDER_KEY = "resolved-secret";
|
||||
try {
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "workflow.yaml"),
|
||||
`config:
|
||||
maxDepth: 1
|
||||
extract:
|
||||
baseUrl: https://example.com
|
||||
model: m
|
||||
apiKey: env:WF_GET_EXTRACT_PROVIDER_KEY
|
||||
workflows: {}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const r = await getExtractProvider(root);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.apiKey).toBe("resolved-secret");
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env.WF_GET_EXTRACT_PROVIDER_KEY;
|
||||
} else {
|
||||
process.env.WF_GET_EXTRACT_PROVIDER_KEY = prev;
|
||||
}
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
selectForkHistoricalSteps,
|
||||
} from "../src/fork-thread.js";
|
||||
|
||||
const sampleDataJsonl = `{"name":"demo","hash":"C9NMV6V2TQT81","threadId":"01AAA1111111111111111111","parameters":{"prompt":"hi","options":{"maxRounds":5}},"timestamp":100}
|
||||
{"role":"planner","contentHash":"HP0000000000000000000001","meta":{},"refs":[],"timestamp":101}
|
||||
{"role":"coder","contentHash":"HP0000000000000000000002","meta":{},"refs":[],"timestamp":102}
|
||||
{"role":"reviewer","contentHash":"HP0000000000000000000003","meta":{},"refs":[],"timestamp":103}
|
||||
const sampleDataJsonl = `{"name":"demo","hash":"C9NMV6V2TQT81","threadId":"01AAA1111111111111111111","parameters":{"prompt":"hi","options":{"isDryRun":false,"maxRounds":5}},"timestamp":100}
|
||||
{"role":"planner","content":"p","meta":{},"timestamp":101}
|
||||
{"role":"coder","content":"c","meta":{},"timestamp":102}
|
||||
{"role":"reviewer","content":"r","meta":{},"timestamp":103}
|
||||
`;
|
||||
|
||||
describe("fork-thread", () => {
|
||||
@@ -23,8 +23,6 @@ describe("fork-thread", () => {
|
||||
expect(r.value.start.hash).toBe("C9NMV6V2TQT81");
|
||||
expect(r.value.start.threadId).toBe("01AAA1111111111111111111");
|
||||
expect(r.value.start.prompt).toBe("hi");
|
||||
expect(r.value.start.maxRounds).toBe(5);
|
||||
expect(r.value.start.depth).toBe(0);
|
||||
expect(r.value.roleSteps.length).toBe(3);
|
||||
expect(r.value.roleSteps[0]?.role).toBe("planner");
|
||||
});
|
||||
@@ -84,24 +82,5 @@ describe("fork-thread", () => {
|
||||
expect(r.value.workflowName).toBe("demo");
|
||||
expect(r.value.historicalSteps.length).toBe(1);
|
||||
expect(r.value.historicalSteps[0]?.timestamp).toBe(101);
|
||||
expect(r.value.runOptions).toEqual({ maxRounds: 5, depth: 0 });
|
||||
});
|
||||
|
||||
test("parseThreadDataJsonl reads explicit depth from start record", () => {
|
||||
const text = `{"name":"demo","hash":"H","threadId":"01ZZZZZZZZZZZZZZZZZZZZZZ","parameters":{"prompt":"p","options":{"maxRounds":3,"depth":2}},"timestamp":1}
|
||||
{"role":"planner","contentHash":"HP0000000000000000000099","meta":{},"refs":[],"timestamp":2}
|
||||
`;
|
||||
const r = parseThreadDataJsonl(text);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.start.depth).toBe(2);
|
||||
const plan = buildForkPlan(text, null);
|
||||
expect(plan.ok).toBe(true);
|
||||
if (!plan.ok) {
|
||||
return;
|
||||
}
|
||||
expect(plan.value.runOptions).toEqual({ maxRounds: 3, depth: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { createContentMerkleNode, parseMerkleNode, serializeMerkleNode } from "../src/merkle.js";
|
||||
|
||||
describe("merkle", () => {
|
||||
test("content node roundtrips through YAML", () => {
|
||||
const node = createContentMerkleNode("hello\nworld");
|
||||
const yaml = serializeMerkleNode(node);
|
||||
const back = parseMerkleNode(yaml);
|
||||
expect(back).toEqual(node);
|
||||
});
|
||||
|
||||
test("step node with object payload roundtrips", () => {
|
||||
const node = {
|
||||
type: "step" as const,
|
||||
payload: { role: "planner", foo: 1 },
|
||||
children: ["ABC123", "DEF456"],
|
||||
};
|
||||
const yaml = serializeMerkleNode(node);
|
||||
const back = parseMerkleNode(yaml);
|
||||
expect(back.type).toBe("step");
|
||||
expect(back.payload).toEqual({ role: "planner", foo: 1 });
|
||||
expect(back.children).toEqual(["ABC123", "DEF456"]);
|
||||
});
|
||||
|
||||
test("parse rejects invalid YAML root", () => {
|
||||
expect(() => parseMerkleNode("[]")).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,209 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createCasStore } from "../src/cas.js";
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "../src/merkle.js";
|
||||
import { reactExtract } from "../src/react-extract.js";
|
||||
import type { LlmProvider } from "../src/types.js";
|
||||
|
||||
const metaSchema = z.object({ seen: z.string() });
|
||||
|
||||
const provider: LlmProvider = {
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "test",
|
||||
model: "test",
|
||||
};
|
||||
|
||||
describe("reactExtract", () => {
|
||||
let restoreFetch: (() => void) | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch?.();
|
||||
restoreFetch = null;
|
||||
});
|
||||
|
||||
test("cas_get rounds then extract tool yields validated meta", async () => {
|
||||
const casDir = await mkdtemp(join(tmpdir(), "react-extract-"));
|
||||
const cas = createCasStore(casDir);
|
||||
try {
|
||||
const blob = serializeMerkleNode(createContentMerkleNode("needle"));
|
||||
const h = await cas.put(blob);
|
||||
|
||||
const origFetch = globalThis.fetch;
|
||||
let round = 0;
|
||||
restoreFetch = () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
globalThis.fetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
||||
round += 1;
|
||||
if (round === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "t1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "cas_get",
|
||||
arguments: JSON.stringify({ hash: h }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "t2",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "extract",
|
||||
arguments: JSON.stringify({ seen: "needle" }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
},
|
||||
{ preconnect: origFetch.preconnect.bind(origFetch) },
|
||||
) as typeof fetch;
|
||||
|
||||
const text = `## Agent Output\n${h}\n## Extraction Instruction\nExtract seen from CAS.`;
|
||||
const result = await reactExtract({
|
||||
text,
|
||||
schema: metaSchema,
|
||||
provider,
|
||||
cas,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.value).toEqual({ seen: "needle" });
|
||||
expect(round).toBe(2);
|
||||
} finally {
|
||||
await rm(casDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("stops after max tool rounds when model keeps calling cas_get", async () => {
|
||||
const casDir = await mkdtemp(join(tmpdir(), "react-extract-max-"));
|
||||
const cas = createCasStore(casDir);
|
||||
try {
|
||||
const blob = serializeMerkleNode(createContentMerkleNode("x"));
|
||||
const h = await cas.put(blob);
|
||||
|
||||
const origFetch = globalThis.fetch;
|
||||
let round = 0;
|
||||
restoreFetch = () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
globalThis.fetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
||||
round += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: `loop-${round}`,
|
||||
type: "function",
|
||||
function: {
|
||||
name: "cas_get",
|
||||
arguments: JSON.stringify({ hash: h }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
},
|
||||
{ preconnect: origFetch.preconnect.bind(origFetch) },
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await reactExtract({
|
||||
text: "## Agent Output\nnoop\n## Extraction Instruction\nExtract seen.",
|
||||
schema: metaSchema,
|
||||
provider,
|
||||
cas,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.error).toBe("max_react_rounds_exceeded");
|
||||
expect(round).toBe(10);
|
||||
} finally {
|
||||
await rm(casDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("passthrough JSON assistant message without tool calls", async () => {
|
||||
const casDir = await mkdtemp(join(tmpdir(), "react-extract-pass-"));
|
||||
const cas = createCasStore(casDir);
|
||||
try {
|
||||
const origFetch = globalThis.fetch;
|
||||
restoreFetch = () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
globalThis.fetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: '{"seen":"direct"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
{ preconnect: origFetch.preconnect.bind(origFetch) },
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await reactExtract({
|
||||
text: "## Agent Output\nok\n## Extraction Instruction\nExtract.",
|
||||
schema: metaSchema,
|
||||
provider,
|
||||
cas,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.value).toEqual({ seen: "direct" });
|
||||
} finally {
|
||||
await rm(casDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,203 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createCasStore } from "../src/cas.js";
|
||||
import { createWorkflow } from "../src/create-workflow.js";
|
||||
import { executeThread } from "../src/engine.js";
|
||||
import { createExtract } from "../src/extract-fn.js";
|
||||
import { buildForkPlan, parseThreadDataJsonl } from "../src/fork-thread.js";
|
||||
import { createLogger } from "../src/logger.js";
|
||||
import { END } from "../src/types.js";
|
||||
|
||||
const phaseSchema = z.object({
|
||||
hash: z.string(),
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
const plannerMetaSchema = z.object({
|
||||
phases: z.array(phaseSchema),
|
||||
});
|
||||
|
||||
type RefsDemoMeta = {
|
||||
planner: z.infer<typeof plannerMetaSchema>;
|
||||
};
|
||||
|
||||
function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unknown>>): () => void {
|
||||
const origFetch = globalThis.fetch;
|
||||
let i = 0;
|
||||
const mockFetch = async (
|
||||
input: Parameters<typeof fetch>[0],
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||
if (args === undefined) {
|
||||
throw new Error("installMockChatCompletions: empty sequence");
|
||||
}
|
||||
i += 1;
|
||||
void input;
|
||||
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
|
||||
const tools = body.tools;
|
||||
const firstTool =
|
||||
Array.isArray(tools) && tools.length > 0 && tools[0] !== null && typeof tools[0] === "object"
|
||||
? (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, {
|
||||
preconnect: origFetch.preconnect.bind(origFetch),
|
||||
}) as typeof fetch;
|
||||
return () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
}
|
||||
|
||||
const refsDemoExtract = createExtract({
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "test",
|
||||
model: "test",
|
||||
});
|
||||
|
||||
const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
|
||||
{
|
||||
roles: {
|
||||
planner: {
|
||||
description: "Planner with phase hashes",
|
||||
systemPrompt: "Plan.",
|
||||
extractPrompt: "Extract phases with CAS hashes.",
|
||||
schema: plannerMetaSchema,
|
||||
extractRefs: (meta) => meta.phases.map((p) => p.hash),
|
||||
extractMode: "single",
|
||||
},
|
||||
},
|
||||
moderator: (ctx) => (ctx.steps.length === 0 ? "planner" : END),
|
||||
},
|
||||
{
|
||||
agent: async () => "plan-output",
|
||||
},
|
||||
refsDemoExtract,
|
||||
null,
|
||||
);
|
||||
|
||||
describe("RoleStep refs tracking", () => {
|
||||
let restoreFetch: (() => void) | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch?.();
|
||||
restoreFetch = null;
|
||||
});
|
||||
|
||||
test("parseThreadDataJsonl reads refs and defaults missing refs to []", () => {
|
||||
const text = `{"name":"demo","hash":"C9NMV6V2TQT81","threadId":"01AAA1111111111111111111","parameters":{"prompt":"hi","options":{"maxRounds":5}},"timestamp":100}
|
||||
{"role":"planner","contentHash":"HPAYLOAD111111","meta":{},"refs":["H111AAAAAAAAA","H222AAAAAAAAA"],"timestamp":101}
|
||||
{"role":"coder","contentHash":"HPAYLOAD222222","meta":{},"timestamp":102}
|
||||
`;
|
||||
const r = parseThreadDataJsonl(text);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.roleSteps[0]?.refs).toEqual(["H111AAAAAAAAA", "H222AAAAAAAAA"]);
|
||||
expect(r.value.roleSteps[1]?.refs).toEqual([]);
|
||||
});
|
||||
|
||||
test("executeThread persists refs from extractRefs on role yields", async () => {
|
||||
restoreFetch = installMockChatCompletions([
|
||||
{
|
||||
phases: [
|
||||
{ hash: "C9NMV6V2TQT81", title: "phase-a" },
|
||||
{ hash: "C9NMV6V2TQT82", title: "phase-b" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-refs-"));
|
||||
try {
|
||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||
const hash = "C9NMV6V2TQT81";
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
|
||||
const result = await executeThread(
|
||||
refsDemoWorkflow,
|
||||
"refs-demo",
|
||||
{ prompt: "task", steps: [] },
|
||||
{
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
signal: ac.signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
expect(typeof result.rootHash).toBe("string");
|
||||
expect(result.rootHash.length).toBeGreaterThan(0);
|
||||
|
||||
const dataText = await readFile(dataPath, "utf8");
|
||||
const lines = dataText
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(lines.length).toBe(2);
|
||||
|
||||
const role1 = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
||||
expect(role1.role).toBe("planner");
|
||||
const refs = role1.refs as string[];
|
||||
expect(refs).toContain("C9NMV6V2TQT81");
|
||||
expect(refs).toContain("C9NMV6V2TQT82");
|
||||
expect(typeof role1.contentHash).toBe("string");
|
||||
expect(refs).toContain(String(role1.contentHash));
|
||||
expect(refs.length).toBe(3);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("buildForkPlan carries refs on historical steps", () => {
|
||||
const text = `{"name":"demo","hash":"C9NMV6V2TQT81","threadId":"01AAA1111111111111111111","parameters":{"prompt":"hi","options":{"maxRounds":5}},"timestamp":100}
|
||||
{"role":"planner","contentHash":"HP111111111111","meta":{},"refs":["KEEPREFAAAAAA"],"timestamp":101}
|
||||
{"role":"coder","contentHash":"HP222222222222","meta":{},"refs":["CODERHASHAAAA"],"timestamp":102}
|
||||
`;
|
||||
const plan = buildForkPlan(text, null);
|
||||
expect(plan.ok).toBe(true);
|
||||
if (!plan.ok) {
|
||||
return;
|
||||
}
|
||||
expect(plan.value.historicalSteps.length).toBe(1);
|
||||
expect(plan.value.historicalSteps[0]?.refs).toEqual(["KEEPREFAAAAAA"]);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
parseWorkflowRegistryYaml,
|
||||
readWorkflowRegistry,
|
||||
registerWorkflowVersion,
|
||||
rollbackWorkflowToHistoryHash,
|
||||
@@ -22,7 +21,6 @@ describe("workflow registry", () => {
|
||||
if (!empty.ok) {
|
||||
return;
|
||||
}
|
||||
expect(empty.value.config).toBeNull();
|
||||
|
||||
const r1 = registerWorkflowVersion(empty.value, "solve-issue", "AAAAAAAAAAAAA", 100);
|
||||
const w1 = await writeWorkflowRegistry(dir, r1);
|
||||
@@ -70,7 +68,7 @@ describe("workflow registry", () => {
|
||||
});
|
||||
|
||||
test("rollbackWorkflowToHistoryHash swaps head with a prior version", () => {
|
||||
let reg = registerWorkflowVersion({ config: null, workflows: {} }, "solve-issue", "H1", 100);
|
||||
let reg = registerWorkflowVersion({ workflows: {} }, "solve-issue", "H1", 100);
|
||||
reg = registerWorkflowVersion(reg, "solve-issue", "H2", 200);
|
||||
reg = registerWorkflowVersion(reg, "solve-issue", "H3", 300);
|
||||
const entry = reg.workflows["solve-issue"];
|
||||
@@ -101,85 +99,6 @@ describe("workflow registry", () => {
|
||||
expect(bad.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("parses config section and literal apiKey", () => {
|
||||
const yaml = `
|
||||
config:
|
||||
maxDepth: 3
|
||||
extract:
|
||||
baseUrl: https://example.com/v1
|
||||
model: qwen-plus
|
||||
apiKey: secret-key
|
||||
workflows:
|
||||
solve-issue:
|
||||
hash: SPVR4BDMSGC1W
|
||||
timestamp: 1
|
||||
history: []
|
||||
`;
|
||||
const r = parseWorkflowRegistryYaml(yaml);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.config).not.toBeNull();
|
||||
if (r.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.config.maxDepth).toBe(3);
|
||||
expect(r.value.config.extract.baseUrl).toBe("https://example.com/v1");
|
||||
expect(r.value.config.extract.model).toBe("qwen-plus");
|
||||
expect(r.value.config.extract.apiKey).toBe("secret-key");
|
||||
});
|
||||
|
||||
test("parses config apiKey env: prefix from process.env", () => {
|
||||
const prev = process.env.WF_REGISTRY_TEST_API_KEY;
|
||||
process.env.WF_REGISTRY_TEST_API_KEY = "from-env";
|
||||
try {
|
||||
const yaml = `
|
||||
config:
|
||||
maxDepth: 1
|
||||
extract:
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
model: qwen-plus
|
||||
apiKey: env:WF_REGISTRY_TEST_API_KEY
|
||||
workflows: {}
|
||||
`;
|
||||
const r = parseWorkflowRegistryYaml(yaml);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.config?.extract.apiKey).toBe("from-env");
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env.WF_REGISTRY_TEST_API_KEY;
|
||||
} else {
|
||||
process.env.WF_REGISTRY_TEST_API_KEY = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("parse errors when env: apiKey variable is unset", () => {
|
||||
const prev = process.env.WF_REGISTRY_TEST_API_KEY_UNSET;
|
||||
delete process.env.WF_REGISTRY_TEST_API_KEY_UNSET;
|
||||
try {
|
||||
const yaml = `
|
||||
config:
|
||||
maxDepth: 1
|
||||
extract:
|
||||
baseUrl: https://example.com
|
||||
model: m
|
||||
apiKey: env:WF_REGISTRY_TEST_API_KEY_UNSET
|
||||
workflows: {}
|
||||
`;
|
||||
const r = parseWorkflowRegistryYaml(yaml);
|
||||
expect(r.ok).toBe(false);
|
||||
} finally {
|
||||
if (prev !== undefined) {
|
||||
process.env.WF_REGISTRY_TEST_API_KEY_UNSET = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("parse errors on invalid shape", async () => {
|
||||
const dir = join(tmpdir(), `wf-reg3-${process.pid}-${Date.now()}`);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "../src/storage-root.js";
|
||||
|
||||
describe("getGlobalCasDir", () => {
|
||||
test("joins cas segment under explicit storage root", () => {
|
||||
expect(getGlobalCasDir("/tmp/wf-root")).toBe(join("/tmp/wf-root", "cas"));
|
||||
});
|
||||
|
||||
test("defaults to default workflow root when storage root is undefined", () => {
|
||||
expect(getGlobalCasDir(undefined)).toBe(join(getDefaultWorkflowStorageRoot(), "cas"));
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,8 @@ describe("RFC-001 thread JSONL shapes", () => {
|
||||
parameters: {
|
||||
prompt: "Fix the login redirect bug in #3",
|
||||
options: {
|
||||
isDryRun: false,
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
},
|
||||
},
|
||||
timestamp: 1714963200000,
|
||||
@@ -18,18 +18,15 @@ describe("RFC-001 thread JSONL shapes", () => {
|
||||
|
||||
const roleRecord = {
|
||||
role: "planner",
|
||||
contentHash: "CPHASH000000000000000001",
|
||||
content: "Plan: modify auth middleware...",
|
||||
meta: { plan: "...", files: ["src/auth.ts"] },
|
||||
refs: [] as string[],
|
||||
timestamp: 1714963201000,
|
||||
};
|
||||
|
||||
expect(Object.keys(startRecord).sort()).toEqual(
|
||||
["hash", "name", "parameters", "threadId", "timestamp"].sort(),
|
||||
);
|
||||
expect(Object.keys(roleRecord).sort()).toEqual(
|
||||
["contentHash", "meta", "refs", "role", "timestamp"].sort(),
|
||||
);
|
||||
expect(Object.keys(roleRecord).sort()).toEqual(["content", "meta", "role", "timestamp"].sort());
|
||||
});
|
||||
|
||||
test("documents the `.info.jsonl` debug record keys", () => {
|
||||
|
||||
@@ -5,29 +5,22 @@ import { createConnection } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createCasStore } from "../src/cas.js";
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "../src/merkle.js";
|
||||
import { getWorkerHostScriptPath } from "../src/worker-entry-path.js";
|
||||
|
||||
const bundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
|
||||
export const descriptor = {
|
||||
const bundleSource = `export const descriptor = {
|
||||
description: "worker-test",
|
||||
roles: {
|
||||
planner: { description: "planner", schema: {} },
|
||||
coder: { description: "coder", schema: {} },
|
||||
},
|
||||
};
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
export const run = async function* (input) {
|
||||
const has = (r) => input.steps.some((s) => s.role === r);
|
||||
if (!has("planner")) {
|
||||
const h = await putContentMerkleNode(cas, "p");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
yield { role: "planner", content: "p", meta: { plan: input.prompt } };
|
||||
}
|
||||
if (!has("coder")) {
|
||||
const h = await putContentMerkleNode(cas, "c");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
yield { role: "coder", content: "c", meta: { diff: "y" } };
|
||||
}
|
||||
return { returnCode: 0, summary: "completed: moderator returned END" };
|
||||
};
|
||||
@@ -109,7 +102,7 @@ describe("worker process", () => {
|
||||
threadId,
|
||||
workflowName: "demo-flow",
|
||||
prompt: "hello",
|
||||
options: { maxRounds: 5, depth: 0 },
|
||||
options: { isDryRun: false, maxRounds: 5 },
|
||||
});
|
||||
|
||||
const exitCode: number = await new Promise((resolve) => {
|
||||
@@ -150,11 +143,6 @@ describe("worker process", () => {
|
||||
|
||||
const port = await readReadyPort(child);
|
||||
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
const plannerReplayHash = await cas.put(
|
||||
serializeMerkleNode(createContentMerkleNode("p-old")),
|
||||
);
|
||||
|
||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||
const srcId = "01SRCMMMMMMMMMMMMMMMMMMMM";
|
||||
await sendJson(port, {
|
||||
@@ -162,13 +150,12 @@ describe("worker process", () => {
|
||||
threadId,
|
||||
workflowName: "demo-flow",
|
||||
prompt: "hello",
|
||||
options: { maxRounds: 5, depth: 0 },
|
||||
options: { isDryRun: false, maxRounds: 5 },
|
||||
steps: [
|
||||
{
|
||||
role: "planner",
|
||||
contentHash: plannerReplayHash,
|
||||
content: "p-old",
|
||||
meta: { plan: "z" },
|
||||
refs: [plannerReplayHash],
|
||||
timestamp: 555,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { createCasStore } from "../src/cas.js";
|
||||
import { createWorkflow } from "../src/create-workflow.js";
|
||||
import { executeThread } from "../src/engine.js";
|
||||
import { createExtract } from "../src/extract-fn.js";
|
||||
import { hashWorkflowBundleBytes } from "../src/hash.js";
|
||||
import { createLogger } from "../src/logger.js";
|
||||
import { getContentMerklePayload, parseMerkleNode } from "../src/merkle.js";
|
||||
import {
|
||||
readWorkflowRegistry,
|
||||
registerWorkflowVersion,
|
||||
writeWorkflowRegistry,
|
||||
} from "../src/registry.js";
|
||||
import { END } from "../src/types.js";
|
||||
import { workflowAsAgent } from "../src/workflow-as-agent.js";
|
||||
|
||||
const callerMetaSchema = z.object({ done: z.literal(true) });
|
||||
|
||||
type ParentMeta = {
|
||||
caller: z.infer<typeof callerMetaSchema>;
|
||||
};
|
||||
|
||||
function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unknown>>): () => void {
|
||||
const origFetch = globalThis.fetch;
|
||||
let i = 0;
|
||||
const mockFetch = async (
|
||||
input: Parameters<typeof fetch>[0],
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||
if (args === undefined) {
|
||||
throw new Error("installMockChatCompletions: empty sequence");
|
||||
}
|
||||
i += 1;
|
||||
void input;
|
||||
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
|
||||
const tools = body.tools;
|
||||
const firstTool =
|
||||
Array.isArray(tools) && tools.length > 0 && tools[0] !== null && typeof tools[0] === "object"
|
||||
? (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, {
|
||||
preconnect: origFetch.preconnect.bind(origFetch),
|
||||
}) as typeof fetch;
|
||||
return () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
}
|
||||
|
||||
const parentExtract = createExtract({
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "test",
|
||||
model: "test",
|
||||
});
|
||||
|
||||
const childBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
|
||||
export const descriptor = {
|
||||
description: "child-integration",
|
||||
roles: {
|
||||
agent: {
|
||||
description: "agent",
|
||||
schema: { type: "object", properties: {}, additionalProperties: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
export async function* run(input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "child-body");
|
||||
yield { role: "agent", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "child-done:" + input.prompt };
|
||||
}
|
||||
`;
|
||||
|
||||
async function installChildWorkflow(storageRoot: string): Promise<{ hash: string }> {
|
||||
const bytes = new TextEncoder().encode(childBundleSource);
|
||||
const hash = hashWorkflowBundleBytes(bytes);
|
||||
await mkdir(join(storageRoot, "bundles"), { recursive: true });
|
||||
await writeFile(join(storageRoot, "bundles", `${hash}.esm.js`), childBundleSource, "utf8");
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
if (!reg.ok) {
|
||||
throw reg.error;
|
||||
}
|
||||
const next = registerWorkflowVersion(reg.value, "child-wf", hash, Date.now());
|
||||
const wr = await writeWorkflowRegistry(storageRoot, next);
|
||||
if (!wr.ok) {
|
||||
throw wr.error;
|
||||
}
|
||||
return { hash };
|
||||
}
|
||||
|
||||
describe("workflowAsAgent integration", () => {
|
||||
let restoreFetch: (() => void) | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch?.();
|
||||
restoreFetch = null;
|
||||
});
|
||||
|
||||
test("createWorkflow parent invokes nested workflow via workflowAsAgent", async () => {
|
||||
restoreFetch = installMockChatCompletions([{ done: true }]);
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-waa-int-"));
|
||||
try {
|
||||
const { hash: childHash } = await installChildWorkflow(root);
|
||||
|
||||
const parentWorkflow = createWorkflow<ParentMeta>(
|
||||
{
|
||||
roles: {
|
||||
caller: {
|
||||
description: "delegates to child workflow",
|
||||
systemPrompt: "system",
|
||||
extractPrompt: "extract done flag",
|
||||
schema: callerMetaSchema,
|
||||
extractRefs: null,
|
||||
extractMode: "single",
|
||||
},
|
||||
},
|
||||
moderator: (ctx) => (ctx.steps.length === 0 ? "caller" : END),
|
||||
},
|
||||
{ agent: workflowAsAgent("child-wf", { storageRoot: root }) },
|
||||
parentExtract,
|
||||
null,
|
||||
);
|
||||
|
||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||
const parentHash = "C9NMV6V2TQT81";
|
||||
const dataPath = join(root, "logs", parentHash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", parentHash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", parentHash), { recursive: true });
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
|
||||
const result = await executeThread(
|
||||
parentWorkflow,
|
||||
"parent-wf",
|
||||
{ prompt: "from-parent", steps: [] },
|
||||
{
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
signal: ac.signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
},
|
||||
{ threadId, hash: parentHash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
expect(typeof result.rootHash).toBe("string");
|
||||
|
||||
const parentText = await readFile(dataPath, "utf8");
|
||||
const parentLines = parentText
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(parentLines.length).toBe(2);
|
||||
const callerLine = JSON.parse(parentLines[1] ?? "{}") as Record<string, unknown>;
|
||||
expect(callerLine.role).toBe("caller");
|
||||
const childRootHash = await getContentMerklePayload(cas, String(callerLine.contentHash));
|
||||
expect(childRootHash).not.toBeNull();
|
||||
const childThreadYaml = await cas.get(childRootHash ?? "");
|
||||
expect(childThreadYaml).not.toBeNull();
|
||||
const childThreadNode = parseMerkleNode(childThreadYaml ?? "");
|
||||
expect(childThreadNode.type).toBe("thread");
|
||||
const childPayload = childThreadNode.payload as Record<string, unknown>;
|
||||
expect(childPayload.workflow).toBe("child-wf");
|
||||
const childResult = childPayload.result as Record<string, unknown>;
|
||||
expect(childResult.summary).toBe("child-done:from-parent");
|
||||
|
||||
const childDir = join(root, "logs", childHash);
|
||||
const childFiles = await readdir(childDir);
|
||||
const childDataName = childFiles.find((n) => n.endsWith(".data.jsonl"));
|
||||
expect(childDataName).toBeDefined();
|
||||
|
||||
const childText = await readFile(join(childDir, childDataName ?? ""), "utf8");
|
||||
const childStart = JSON.parse(
|
||||
childText
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "")[0] ?? "{}",
|
||||
) as Record<string, unknown>;
|
||||
expect(childStart.forkFrom).toEqual({ threadId });
|
||||
const childOpts = (childStart.parameters as Record<string, unknown>).options as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(childOpts.depth).toBe(1);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,168 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createCasStore } from "../src/cas.js";
|
||||
import { hashWorkflowBundleBytes } from "../src/hash.js";
|
||||
import { parseMerkleNode } from "../src/merkle.js";
|
||||
import {
|
||||
readWorkflowRegistry,
|
||||
registerWorkflowVersion,
|
||||
writeWorkflowRegistry,
|
||||
} from "../src/registry.js";
|
||||
import { type AgentContext, START } from "../src/types.js";
|
||||
import { workflowAsAgent } from "../src/workflow-as-agent.js";
|
||||
|
||||
function makeAgentCtx(params: {
|
||||
storageRoot: string;
|
||||
depth: number;
|
||||
prompt: string;
|
||||
maxRounds: number;
|
||||
}): AgentContext {
|
||||
const ts = Date.now();
|
||||
return {
|
||||
threadId: "01PARENT000000000000000001AA",
|
||||
depth: params.depth,
|
||||
start: {
|
||||
role: START,
|
||||
content: params.prompt,
|
||||
meta: { maxRounds: params.maxRounds },
|
||||
timestamp: ts,
|
||||
},
|
||||
steps: [],
|
||||
currentRole: {
|
||||
name: "caller",
|
||||
systemPrompt: "caller",
|
||||
},
|
||||
cas: createCasStore(join(params.storageRoot, "agent-ctx-cas")),
|
||||
};
|
||||
}
|
||||
|
||||
const childBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
|
||||
export const descriptor = {
|
||||
description: "child-test",
|
||||
roles: {
|
||||
agent: {
|
||||
description: "agent",
|
||||
schema: { type: "object", properties: {}, additionalProperties: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
export async function* run(input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "child-body");
|
||||
yield { role: "agent", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "child-done:" + input.prompt };
|
||||
}
|
||||
`;
|
||||
|
||||
async function installChildWorkflow(storageRoot: string): Promise<{ hash: string }> {
|
||||
const bytes = new TextEncoder().encode(childBundleSource);
|
||||
const hash = hashWorkflowBundleBytes(bytes);
|
||||
await mkdir(join(storageRoot, "bundles"), { recursive: true });
|
||||
await writeFile(join(storageRoot, "bundles", `${hash}.esm.js`), childBundleSource, "utf8");
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
if (!reg.ok) {
|
||||
throw reg.error;
|
||||
}
|
||||
const next = registerWorkflowVersion(reg.value, "child-wf", hash, Date.now());
|
||||
const wr = await writeWorkflowRegistry(storageRoot, next);
|
||||
if (!wr.ok) {
|
||||
throw wr.error;
|
||||
}
|
||||
return { hash };
|
||||
}
|
||||
|
||||
describe("workflowAsAgent", () => {
|
||||
test("returns error when workflow name is not registered", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-waa-missing-"));
|
||||
try {
|
||||
const agent = workflowAsAgent("missing-wf", { storageRoot: root });
|
||||
const out = await agent(
|
||||
makeAgentCtx({ storageRoot: root, depth: 0, prompt: "x", maxRounds: 5 }),
|
||||
);
|
||||
expect(out).toContain("not found in registry");
|
||||
expect(out).toContain("missing-wf");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("runs registered workflow and returns child thread root CAS hash", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-waa-ok-"));
|
||||
try {
|
||||
await installChildWorkflow(root);
|
||||
const agent = workflowAsAgent("child-wf", { storageRoot: root });
|
||||
const out = await agent(
|
||||
makeAgentCtx({ storageRoot: root, depth: 0, prompt: "hello-parent", maxRounds: 5 }),
|
||||
);
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
const threadYaml = await cas.get(out);
|
||||
expect(threadYaml).not.toBeNull();
|
||||
const node = parseMerkleNode(threadYaml ?? "");
|
||||
expect(node.type).toBe("thread");
|
||||
const payload = node.payload as Record<string, unknown>;
|
||||
expect(payload.workflow).toBe("child-wf");
|
||||
const resultObj = payload.result as Record<string, unknown>;
|
||||
expect(resultObj.summary).toBe("child-done:hello-parent");
|
||||
expect(node.children.length).toBe(1);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("enforces depth limit (returns error string, does not throw)", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-waa-depth-"));
|
||||
try {
|
||||
const agent = workflowAsAgent("child-wf", { storageRoot: root });
|
||||
const out = await agent(
|
||||
makeAgentCtx({ storageRoot: root, depth: 3, prompt: "x", maxRounds: 5 }),
|
||||
);
|
||||
expect(out).toContain("depth limit");
|
||||
expect(out).toContain("max 3");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("uses registry config maxDepth when set", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-waa-maxdepth-cfg-"));
|
||||
try {
|
||||
await installChildWorkflow(root);
|
||||
const reg = await readWorkflowRegistry(root);
|
||||
expect(reg.ok).toBe(true);
|
||||
if (!reg.ok) {
|
||||
return;
|
||||
}
|
||||
const withCfg = {
|
||||
...reg.value,
|
||||
config: {
|
||||
maxDepth: 2,
|
||||
extract: {
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
model: "m",
|
||||
apiKey: "k",
|
||||
},
|
||||
},
|
||||
};
|
||||
const wr = await writeWorkflowRegistry(root, withCfg);
|
||||
expect(wr.ok).toBe(true);
|
||||
|
||||
const agent = workflowAsAgent("child-wf", { storageRoot: root });
|
||||
const okOut = await agent(
|
||||
makeAgentCtx({ storageRoot: root, depth: 1, prompt: "nest-once", maxRounds: 5 }),
|
||||
);
|
||||
expect(okOut).not.toContain("depth limit");
|
||||
|
||||
const badOut = await agent(
|
||||
makeAgentCtx({ storageRoot: root, depth: 2, prompt: "x", maxRounds: 5 }),
|
||||
);
|
||||
expect(badOut).toContain("depth limit");
|
||||
expect(badOut).toContain("max 2");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,196 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { validateWorkflowDescriptor } from "../src/workflow-descriptor.js";
|
||||
|
||||
describe("validateWorkflowDescriptor", () => {
|
||||
// 1. Valid minimal descriptor
|
||||
test("accepts a minimal descriptor with empty roles", () => {
|
||||
const result = validateWorkflowDescriptor({ description: "x", roles: {} });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.description).toBe("x");
|
||||
expect(result.value.roles).toEqual({});
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Valid descriptor with one role
|
||||
test("accepts a descriptor with one role", () => {
|
||||
const result = validateWorkflowDescriptor({
|
||||
description: "workflow",
|
||||
roles: {
|
||||
solver: { description: "solves things", schema: { type: "object" } },
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.description).toBe("workflow");
|
||||
expect(result.value.roles.solver.description).toBe("solves things");
|
||||
expect(result.value.roles.solver.schema).toEqual({ type: "object" });
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Valid descriptor with multiple roles
|
||||
test("accepts a descriptor with multiple roles", () => {
|
||||
const result = validateWorkflowDescriptor({
|
||||
description: "multi",
|
||||
roles: {
|
||||
a: { description: "role a", schema: {} },
|
||||
b: { description: "role b", schema: { type: "string" } },
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(Object.keys(result.value.roles)).toEqual(["a", "b"]);
|
||||
}
|
||||
});
|
||||
|
||||
// 4-6. Root is null / array / string / number / undefined
|
||||
test("rejects null", () => {
|
||||
const result = validateWorkflowDescriptor(null);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor must be a non-array object");
|
||||
});
|
||||
|
||||
test("rejects an array", () => {
|
||||
const result = validateWorkflowDescriptor([]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor must be a non-array object");
|
||||
});
|
||||
|
||||
test("rejects a string", () => {
|
||||
const result = validateWorkflowDescriptor("hello");
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor must be a non-array object");
|
||||
});
|
||||
|
||||
test("rejects a number", () => {
|
||||
const result = validateWorkflowDescriptor(42);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor must be a non-array object");
|
||||
});
|
||||
|
||||
test("rejects undefined", () => {
|
||||
const result = validateWorkflowDescriptor(undefined);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor must be a non-array object");
|
||||
});
|
||||
|
||||
// 7-8. Missing or non-string description
|
||||
test("rejects missing description", () => {
|
||||
const result = validateWorkflowDescriptor({ roles: {} });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.description must be a string");
|
||||
});
|
||||
|
||||
test("rejects numeric description", () => {
|
||||
const result = validateWorkflowDescriptor({ description: 123, roles: {} });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.description must be a string");
|
||||
});
|
||||
|
||||
test("rejects null description", () => {
|
||||
const result = validateWorkflowDescriptor({ description: null, roles: {} });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.description must be a string");
|
||||
});
|
||||
|
||||
test("rejects boolean description", () => {
|
||||
const result = validateWorkflowDescriptor({ description: true, roles: {} });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.description must be a string");
|
||||
});
|
||||
|
||||
// 9-11. Missing / null / array roles
|
||||
test("rejects missing roles", () => {
|
||||
const result = validateWorkflowDescriptor({ description: "x" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.roles must be a non-array object");
|
||||
});
|
||||
|
||||
test("rejects null roles", () => {
|
||||
const result = validateWorkflowDescriptor({ description: "x", roles: null });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.roles must be a non-array object");
|
||||
});
|
||||
|
||||
test("rejects array roles", () => {
|
||||
const result = validateWorkflowDescriptor({ description: "x", roles: [] });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.roles must be a non-array object");
|
||||
});
|
||||
|
||||
// 12-13. Role entry is null / array
|
||||
test("rejects null role entry", () => {
|
||||
const result = validateWorkflowDescriptor({ description: "x", roles: { bad: null } });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.roles.bad must be a non-array object");
|
||||
});
|
||||
|
||||
test("rejects array role entry", () => {
|
||||
const result = validateWorkflowDescriptor({ description: "x", roles: { bad: [] } });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.roles.bad must be a non-array object");
|
||||
});
|
||||
|
||||
// 14-15. Role missing description / non-string description
|
||||
test("rejects role with missing description", () => {
|
||||
const result = validateWorkflowDescriptor({
|
||||
description: "x",
|
||||
roles: { r: { schema: {} } },
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.roles.r.description must be a string");
|
||||
});
|
||||
|
||||
test("rejects role with non-string description", () => {
|
||||
const result = validateWorkflowDescriptor({
|
||||
description: "x",
|
||||
roles: { r: { description: 99, schema: {} } },
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.roles.r.description must be a string");
|
||||
});
|
||||
|
||||
// 16-18. Role schema null / array / missing
|
||||
test("rejects role with null schema", () => {
|
||||
const result = validateWorkflowDescriptor({
|
||||
description: "x",
|
||||
roles: { r: { description: "d", schema: null } },
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toBe("descriptor.roles.r.schema must be a non-array object");
|
||||
});
|
||||
|
||||
test("rejects role with array schema", () => {
|
||||
const result = validateWorkflowDescriptor({
|
||||
description: "x",
|
||||
roles: { r: { description: "d", schema: [] } },
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toBe("descriptor.roles.r.schema must be a non-array object");
|
||||
});
|
||||
|
||||
test("rejects role with missing schema", () => {
|
||||
const result = validateWorkflowDescriptor({
|
||||
description: "x",
|
||||
roles: { r: { description: "d" } },
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toBe("descriptor.roles.r.schema must be a non-array object");
|
||||
});
|
||||
|
||||
// 19. First role valid, second role invalid
|
||||
test("rejects at first invalid role when earlier roles are valid", () => {
|
||||
const result = validateWorkflowDescriptor({
|
||||
description: "x",
|
||||
roles: {
|
||||
good: { description: "ok", schema: {} },
|
||||
bad: { description: 123, schema: {} },
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.roles.bad.description must be a string");
|
||||
});
|
||||
});
|
||||
@@ -13,11 +13,7 @@
|
||||
"xxhashjs": "^0.2.2",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/acorn": "^6.0.4",
|
||||
"zod": "^4.0.0"
|
||||
"@types/acorn": "^6.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import type { RoleMeta, WorkflowDefinition } from "./types.js";
|
||||
import type { WorkflowDescriptor, WorkflowRoleSchema } from "./workflow-descriptor.js";
|
||||
|
||||
function stripJsonSchemaMeta(json: Record<string, unknown>): WorkflowRoleSchema {
|
||||
const { $schema: _drop, ...rest } = json;
|
||||
return rest as WorkflowRoleSchema;
|
||||
}
|
||||
|
||||
export function buildDescriptor<M extends RoleMeta>(
|
||||
def: WorkflowDefinition<M>,
|
||||
): WorkflowDescriptor {
|
||||
const roles: WorkflowDescriptor["roles"] = {};
|
||||
for (const [key, roleDef] of Object.entries(def.roles) as Array<
|
||||
[string, { description: string; schema: z.ZodType }]
|
||||
>) {
|
||||
const rawJsonSchema = z.toJSONSchema(roleDef.schema) as Record<string, unknown>;
|
||||
roles[key] = {
|
||||
description: roleDef.description,
|
||||
schema: stripJsonSchemaMeta(rawJsonSchema),
|
||||
};
|
||||
}
|
||||
return { description: def.description, roles };
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
/**
|
||||
* Dynamic-import a workflow bundle path (see {@link extractBundleExports} — symlink must exist first).
|
||||
*/
|
||||
export async function importWorkflowBundleModule(bundlePath: string): Promise<unknown> {
|
||||
return import(pathToFileURL(bundlePath).href);
|
||||
}
|
||||
@@ -41,12 +41,9 @@ function isAllowedImportSpecifier(spec: string): boolean {
|
||||
if (spec.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("file:")) {
|
||||
if (spec.startsWith(".") || spec.startsWith("/")) {
|
||||
return false;
|
||||
}
|
||||
if (spec === "@uncaged/workflow") {
|
||||
return true;
|
||||
}
|
||||
return isBuiltin(spec);
|
||||
}
|
||||
|
||||
@@ -300,7 +297,7 @@ function validateImportDeclaration(node: ImportDeclaration): string | null {
|
||||
return "only static string import specifiers are allowed";
|
||||
}
|
||||
if (!isAllowedImportSpecifier(spec)) {
|
||||
return `disallowed import specifier "${spec}" (only Node built-ins and "@uncaged/workflow" are allowed)`;
|
||||
return `disallowed import specifier "${spec}" (only Node built-ins are allowed)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -315,7 +312,7 @@ function validateExportSource(
|
||||
return staticMessage;
|
||||
}
|
||||
if (!isAllowedImportSpecifier(spec)) {
|
||||
return `${disallowedPrefix} "${spec}" (only Node built-ins and "@uncaged/workflow" are allowed)`;
|
||||
return `${disallowedPrefix} "${spec}" (only Node built-ins are allowed)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -368,7 +365,7 @@ function bundleConstraintViolationForNode(node: Node): string | null {
|
||||
|
||||
/**
|
||||
* Validate RFC-001 bundle rules: single-file ESM shape, named exports `run` + `descriptor`,
|
||||
* no default export, no dynamic `import()`, static imports restricted to Node builtins plus `@uncaged/workflow`.
|
||||
* no default export, no dynamic `import()`, static imports restricted to Node builtins.
|
||||
*/
|
||||
export function validateWorkflowBundle(input: WorkflowBundleValidationInput): Result<void, string> {
|
||||
if (!endsWithEsmJs(input.filePath)) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user