Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94c719870f | |||
| 5af2d54e0f | |||
| e01c08dacb | |||
| f9d3d38008 | |||
| 9e99e58405 | |||
| 6af3059fb4 | |||
| dfeba9d8fc | |||
| 0da1aabfab | |||
| bb3618cc42 | |||
| 2b21d981dd | |||
| ebfb99bf4c | |||
| 33f9425848 | |||
| 2b707fb44e | |||
| 6306b23a9f | |||
| 6bb8cf8315 | |||
| 93b7947d7c | |||
| 9584a86fb7 | |||
| defc0afc27 | |||
| 9f6633d5bf | |||
| 7dadf874e1 | |||
| ba90214af6 | |||
| 5bbac3e4f7 | |||
| 131021b1a7 | |||
| e42555fd9c | |||
| 3a26eb28e5 | |||
| c1a17b707c | |||
| 4ea1e0d8a4 | |||
| b1a9d2ec3f | |||
| 2b8707a706 | |||
| 241bfbf6d9 | |||
| 40530d757e | |||
| 0f3661b566 | |||
| 9c44c709e9 | |||
| 8892ab9978 | |||
| 7ec86d82a3 | |||
| f728b36e8d | |||
| 3431d3070b | |||
| 576df067d4 | |||
| a46a225d04 | |||
| f74b482cc0 | |||
| 89abfdc257 | |||
| 77e395b913 | |||
| b65a006d45 | |||
| 5994548f0b | |||
| 0871ae54ea | |||
| 9576d69032 | |||
| 64dadf114d | |||
| baaa1d1dc8 | |||
| 3074cd5f0c | |||
| 15edc99c72 | |||
| 153178c545 | |||
| fac215bd21 | |||
| 9822e68c55 | |||
| 764b73209e | |||
| e7987c4cd7 | |||
| 942ff4b1a4 | |||
| f5977c46c6 | |||
| 71ccf8d03c | |||
| 510b49287a | |||
| bb6b309efa | |||
| 56db22a908 | |||
| 2a1b7b0aeb | |||
| d037eca4ae | |||
| b9d543a465 | |||
| 07f52594d1 | |||
| c7b426ff5a | |||
| 4582274ba4 | |||
| d140801337 | |||
| 4563f1bb5e | |||
| 59b7e89028 | |||
| 019d8c1ee9 | |||
| 5e783e7a24 | |||
| a450a88b16 | |||
| 5b47317cef | |||
| 3384c38d02 | |||
| b370d96504 | |||
| 8cae114c7e | |||
| c2c6fc5304 | |||
| 9b23e6f85a | |||
| 238a94f7a6 | |||
| 236c771e4e | |||
| 0ffd84cf7d |
@@ -0,0 +1,35 @@
|
|||||||
|
# Uncaged Workflow Architecture
|
||||||
|
|
||||||
|
Uncaged Workflow is a monorepo implementing a workflow engine that executes single-file ESM bundles. Each workflow is identified by an XXH64 hash (Crockford Base32); execution state is stored in a content-addressable store (CAS) as immutable Merkle nodes. Agents are pluggable — the same workflow definition runs with Cursor, Hermes, a raw LLM, or a ReAct loop.
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
| Card | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| [Bundle](./bundle.md) | A single-file `.esm.js` module with an XXH64 hash identity, stored in `~/.uncaged/workflow/bundles/` |
|
||||||
|
| [Thread](./thread.md) | A single execution instance of a workflow, identified by a ULID, with CAS-linked state nodes |
|
||||||
|
| [CAS](./cas.md) | The content-addressable store that holds all immutable blobs — content, start nodes, and state nodes |
|
||||||
|
| [Registry](./registry.md) | `workflow.yaml` — maps workflow names to current and historical bundle hashes |
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
| Card | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| [Engine](./engine.md) | The three-phase loop that drives the workflow `AsyncGenerator` and writes each step to CAS |
|
||||||
|
| [Role](./role.md) | A named actor defined as pure data (`RoleDefinition`) — description, system prompt, and Zod schema |
|
||||||
|
| [Agent Binding](./agent-binding.md) | The runtime binding that connects a role to a concrete agent implementation via `AdapterFn` |
|
||||||
|
| [Reactor](./reactor.md) | The ReAct loop abstraction for LLM function-calling, used by both the extract phase and agent adapters |
|
||||||
|
|
||||||
|
## Tooling
|
||||||
|
|
||||||
|
| Card | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| [CLI](./cli.md) | The `uncaged-workflow` command-line tool for managing workflows, threads, and CAS |
|
||||||
|
| [Dashboard](./dashboard.md) | A private React app for inspecting threads, workflows, and live execution via the gateway |
|
||||||
|
| [Package Map](./package-map.md) | All packages in the monorepo with their layer positions and dependency graph |
|
||||||
|
|
||||||
|
## Authoring
|
||||||
|
|
||||||
|
| Card | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| [Workflow Templates](./workflow-templates.md) | The `solve-issue` and `develop` reference templates and how to author custom workflows |
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Agent Binding
|
||||||
|
|
||||||
|
> The runtime connection between a workflow's role definitions and a concrete agent implementation, expressed as an `AdapterBinding` passed to `createWorkflow`.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Agent binding is how a workflow author specifies which agent executes each role. Roles are pure data (see [Role](./role.md)); the binding supplies the execution strategy. The same `WorkflowDefinition` can be run with different agents by changing the `AdapterBinding` — useful for testing, cost optimization, or environment-specific deployment.
|
||||||
|
|
||||||
|
An `AdapterFn` receives a role's `systemPrompt` and Zod `schema`, and returns a `RoleFn` — a function that takes `ThreadContext` and `WorkflowRuntime` and returns `RoleResult<T>`. The adapter is responsible for producing typed structured output directly; there is no separate extract phase when using adapters.
|
||||||
|
|
||||||
|
## Key Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// The core adapter interface
|
||||||
|
type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
|
||||||
|
|
||||||
|
type RoleFn<T> = (ctx: ThreadContext, runtime: WorkflowRuntime) => Promise<RoleResult<T>>;
|
||||||
|
|
||||||
|
type RoleResult<T> = { meta: T; childThread: string | null };
|
||||||
|
|
||||||
|
// The binding passed to createWorkflow
|
||||||
|
type AdapterBinding = {
|
||||||
|
adapter: AdapterFn;
|
||||||
|
overrides: Partial<Record<string, AdapterFn>> | null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`overrides` allows per-role adapters — for example, using Cursor for one role and an LLM for another within the same workflow.
|
||||||
|
|
||||||
|
## AgentFn (Legacy / Low-level)
|
||||||
|
|
||||||
|
Below the adapter layer, the original `AgentFn` type still exists for agent implementations that produce raw strings rather than structured output:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type AgentFn<Opt = void> = Opt extends void
|
||||||
|
? (ctx: ThreadContext) => Promise<string>
|
||||||
|
: (ctx: ThreadContext, options: Opt) => Promise<string>;
|
||||||
|
```
|
||||||
|
|
||||||
|
The `createAgentAdapter` utility in `@uncaged/workflow-util-agent` wraps an `AgentFn` into an `AdapterFn` by composing it with extraction logic.
|
||||||
|
|
||||||
|
## Concrete Implementations
|
||||||
|
|
||||||
|
| Package | Export | Agent |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| `@uncaged/workflow-agent-cursor` | `createCursorAgent` | Runs `cursor` CLI non-interactively in a workspace directory |
|
||||||
|
| `@uncaged/workflow-agent-hermes` | `createHermesAgent` | Runs `hermes chat` with `--yolo --quiet` (Nerve-style argv) |
|
||||||
|
| `@uncaged/workflow-agent-llm` | `createLlmAdapter` | Direct LLM completion via the OpenAI-compatible chat endpoint |
|
||||||
|
| `@uncaged/workflow-agent-react` | `createReactAdapter` | ReAct loop with file and shell tools (read, write, patch, exec) |
|
||||||
|
|
||||||
|
All four return an `AdapterFn` suitable for use in `AdapterBinding.adapter`.
|
||||||
|
|
||||||
|
## workflow-util-agent
|
||||||
|
|
||||||
|
`@uncaged/workflow-util-agent` provides two helpers shared by adapter implementations:
|
||||||
|
|
||||||
|
- **`buildThreadInput(ctx)`** — constructs the user-message string from thread context (task, previous steps, tool hints). Used by all CLI-based agents.
|
||||||
|
- **`spawnCli(command, args, opts)`** — spawns an external process (e.g., `cursor`, `hermes`) and captures stdout, with optional timeout.
|
||||||
|
- **`createAgentAdapter(agentFn, optionsFn)`** — wraps an `AgentFn<Opt>` into an `AdapterFn`, handling the options extraction step.
|
||||||
|
|
||||||
|
## Cursor Agent
|
||||||
|
|
||||||
|
`createCursorAgent(config)` invokes the `cursor` CLI binary:
|
||||||
|
|
||||||
|
```
|
||||||
|
cursor -p <fullPrompt> --model <model> --workspace <path> --output-format text --trust --force
|
||||||
|
```
|
||||||
|
|
||||||
|
The workspace path is taken from `config.workspace` or extracted from the thread context via `runtime.extract`.
|
||||||
|
|
||||||
|
## Hermes Agent
|
||||||
|
|
||||||
|
`createHermesAgent(config)` invokes `hermes chat`:
|
||||||
|
|
||||||
|
```
|
||||||
|
hermes chat -q <fullPrompt> --yolo --max-turns 90 --quiet [--model <model>]
|
||||||
|
```
|
||||||
|
|
||||||
|
## LLM Adapter
|
||||||
|
|
||||||
|
`createLlmAdapter(provider)` calls the OpenAI-compatible chat completions endpoint directly. It builds a two-message conversation (system + user) from the role's `systemPrompt` and `buildThreadInput` output, then extracts structured output from the response.
|
||||||
|
|
||||||
|
## React Adapter
|
||||||
|
|
||||||
|
`createReactAdapter(config)` creates a ReAct loop agent with four default tools: `read_file`, `write_file`, `patch_file`, and `shell_exec`. The loop continues until the agent calls the structured extraction tool or until `maxRounds` is exceeded.
|
||||||
|
|
||||||
|
## Code Pointers
|
||||||
|
|
||||||
|
| Package | File | What it does |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `@uncaged/workflow-protocol` | `src/types.ts` | `AdapterFn`, `AdapterBinding`, `RoleFn`, `RoleResult`, `AgentFn` |
|
||||||
|
| `@uncaged/workflow-runtime` | `src/create-workflow.ts` | `createWorkflow` — dispatches `adapterForRole` each iteration |
|
||||||
|
| `@uncaged/workflow-util-agent` | `src/build-agent-prompt.ts` | `buildThreadInput`, `buildAgentPrompt` |
|
||||||
|
| `@uncaged/workflow-util-agent` | `src/spawn-cli.ts` | `spawnCli` — subprocess runner with timeout |
|
||||||
|
| `@uncaged/workflow-util-agent` | `src/create-agent-adapter.ts` | `createAgentAdapter` — wraps `AgentFn` into `AdapterFn` |
|
||||||
|
| `@uncaged/workflow-agent-cursor` | `src/index.ts` | `createCursorAgent` |
|
||||||
|
| `@uncaged/workflow-agent-hermes` | `src/index.ts` | `createHermesAgent` |
|
||||||
|
| `@uncaged/workflow-agent-llm` | `src/create-llm-adapter.ts` | `createLlmAdapter` |
|
||||||
|
| `@uncaged/workflow-agent-react` | `src/create-react-adapter.ts` | `createReactAdapter` |
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Role](./role.md) — the pure data that the binding executes
|
||||||
|
- [Engine](./engine.md) — the loop that invokes the bound adapter each step
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Bundle
|
||||||
|
|
||||||
|
> A self-contained single-file ESM module (`.esm.js`) that implements one workflow, identified by its XXH64 hash encoded as 13-char Crockford Base32.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A bundle is the physical unit of workflow distribution. Workflow authors build their TypeScript source into a single ESM file using `bun build` with `@uncaged/*` packages as externals. The resulting `.esm.js` is the artifact that gets registered and executed.
|
||||||
|
|
||||||
|
Every bundle is immutable and content-addressed: its identity is the XXH64 hash of its bytes, encoded as 13 characters of Crockford Base32 (e.g., `3TNKQRJ7BM4XH`). Registering a bundle with a new version simply adds a new hash entry; old hashes stay in the registry history and remain valid.
|
||||||
|
|
||||||
|
Bundles are stored on disk at `~/.uncaged/workflow/bundles/<hash>/` after registration. The `cas/` and `threads.json` for that bundle's execution state live under the same directory.
|
||||||
|
|
||||||
|
## Exports
|
||||||
|
|
||||||
|
Every valid bundle must export exactly two named exports — no default export is permitted:
|
||||||
|
|
||||||
|
| Export | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `run` | `WorkflowFn` | The `AsyncGenerator` that drives the execution loop |
|
||||||
|
| `descriptor` | `WorkflowDescriptor` | Serializable metadata: description, roles, and routing graph |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Minimal bundle shape
|
||||||
|
export const run: WorkflowFn = createWorkflow(def, binding);
|
||||||
|
export const descriptor: WorkflowDescriptor = buildDescriptor(def);
|
||||||
|
```
|
||||||
|
|
||||||
|
The validator in `@uncaged/workflow-register` enforces this contract before a bundle can be registered — see `extractBundleExports`.
|
||||||
|
|
||||||
|
## Hash Algorithm
|
||||||
|
|
||||||
|
The bundle hash is computed with **XXH64** (seed 0) over the raw bytes of the `.esm.js` file, then encoded as 13-char Crockford Base32 using `encodeUint64AsCrockford`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/workflow-cas/src/hash.ts
|
||||||
|
export function hashWorkflowBundleBytes(data: Uint8Array): string {
|
||||||
|
const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
||||||
|
const digest = XXH.h64(0).update(buf).digest();
|
||||||
|
return encodeUint64AsCrockford(digestToUint64(digest));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The same algorithm hashes CAS blob content (`hashString`), so all IDs in the system are consistent Crockford Base32 strings.
|
||||||
|
|
||||||
|
## Build Process
|
||||||
|
|
||||||
|
Bundles are not distributed from the monorepo directly. The typical flow is:
|
||||||
|
|
||||||
|
1. Create a separate workspace (e.g., `my-workflows/`) with `@uncaged/workflow-runtime` as a dependency.
|
||||||
|
2. Write a TypeScript workflow module that imports `createWorkflow` from `@uncaged/workflow-runtime`.
|
||||||
|
3. Run `bun build --entrypoints src/my-workflow.ts --outfile dist/my-workflow.esm.js --format esm --external '@uncaged/*'`.
|
||||||
|
4. Register with `uncaged-workflow workflow add <name> dist/my-workflow.esm.js`.
|
||||||
|
|
||||||
|
## Storage Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.uncaged/workflow/
|
||||||
|
workflow.yaml # registry (name → hash mapping)
|
||||||
|
bundles/
|
||||||
|
<hash>/
|
||||||
|
threads.json # active thread index
|
||||||
|
history/
|
||||||
|
YYYY-MM-DD.jsonl # completed thread records
|
||||||
|
cas/
|
||||||
|
<hash>.txt # CAS blobs (all bundles share one global CAS)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Pointers
|
||||||
|
|
||||||
|
| Package | File | What it does |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `@uncaged/workflow-cas` | `src/hash.ts` | `hashWorkflowBundleBytes` and `hashString` — XXH64 + Crockford encoding |
|
||||||
|
| `@uncaged/workflow-register` | `src/bundle/extract-bundle-exports.ts` | Loads a `.esm.js` bundle and validates `run` + `descriptor` |
|
||||||
|
| `@uncaged/workflow-register` | `src/bundle/bundle-validator.ts` | Schema validation of bundle exports |
|
||||||
|
| `@uncaged/workflow-runtime` | `src/create-workflow.ts` | `createWorkflow` — the primary bundle authoring function |
|
||||||
|
| `@uncaged/workflow-util` | `src/base32.ts` | `encodeUint64AsCrockford` — Crockford Base32 encoding |
|
||||||
|
| `@uncaged/workflow-util` | `src/storage-root.ts` | `getDefaultWorkflowStorageRoot` → `~/.uncaged/workflow` |
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Registry](./registry.md) — how bundles are registered and named in `workflow.yaml`
|
||||||
|
- [Thread](./thread.md) — how a bundle's `run` export is executed as a thread
|
||||||
|
- [Engine](./engine.md) — the executor that drives the bundle's `AsyncGenerator`
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
# CAS (Content-Addressable Storage)
|
||||||
|
|
||||||
|
> An append-only store where every blob is identified by its XXH64 hash, used to persist all workflow thread state as immutable Merkle nodes.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
CAS is the persistence substrate for the entire workflow engine. Rather than mutating a database row, every piece of state — agent output, role metadata, thread start parameters — is serialized as a YAML blob and stored under its hash. Because content determines identity, the same content always maps to the same hash, and writes are idempotent.
|
||||||
|
|
||||||
|
The `CasStore` interface is intentionally simple: `put`, `get`, `delete`, `list`. The default filesystem implementation stores each blob as `<hash>.txt` under `~/.uncaged/workflow/cas/`. Writes use an atomic rename-from-tmp pattern to prevent partial writes.
|
||||||
|
|
||||||
|
## Hash Algorithm
|
||||||
|
|
||||||
|
All hashes in the system are **XXH64** (seed 0) over UTF-8 content, encoded as 13-char Crockford Base32. This applies to both CAS blob hashes and bundle file hashes. The encoding function `encodeUint64AsCrockford` lives in `@uncaged/workflow-util`.
|
||||||
|
|
||||||
|
## Node Types
|
||||||
|
|
||||||
|
The CAS holds three types of YAML nodes, all sharing the `{ type, payload, refs }` envelope:
|
||||||
|
|
||||||
|
### `content` node
|
||||||
|
Stores the raw text output of an agent or the initial prompt. `refs` lists any artifact hashes the content references.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: content
|
||||||
|
payload: "The implementation is complete. Changed files: src/foo.ts"
|
||||||
|
refs:
|
||||||
|
- 3TNKQRJ7BM4XH # optional artifact refs
|
||||||
|
```
|
||||||
|
|
||||||
|
### `start` node
|
||||||
|
Written once when a thread begins. Anchors the thread to a specific workflow name, bundle hash, and depth level.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: start
|
||||||
|
payload:
|
||||||
|
name: solve-issue
|
||||||
|
hash: 3TNKQRJ7BM4XH
|
||||||
|
depth: 0
|
||||||
|
parentState: null
|
||||||
|
refs:
|
||||||
|
- <promptHash>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `state` node
|
||||||
|
Written once per completed role step. Points back to the `start` node, the role's content node, and maintains an ancestor skip-list for traversal.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
type: state
|
||||||
|
payload:
|
||||||
|
role: coder
|
||||||
|
meta: { status: "done", completedPhase: "..." }
|
||||||
|
start: <startHash>
|
||||||
|
content: <contentHash>
|
||||||
|
ancestors: [<prev_state>, ...]
|
||||||
|
compact: null
|
||||||
|
timestamp: 1716000000000
|
||||||
|
childThread: null
|
||||||
|
refs:
|
||||||
|
- <contentHash>
|
||||||
|
- <startHash>
|
||||||
|
- <ancestor hashes>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Merkle Structure
|
||||||
|
|
||||||
|
The `ancestors` array in each `StateNode` implements a **skip-list** capped at 11 entries (1 direct parent + up to 10 skip-list ancestors). This allows `O(log n)` traversal of the chain without loading every node, while keeping each blob self-contained.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
S[StartNode] --> C1[content₁]
|
||||||
|
N1[StateNode₁] --> S
|
||||||
|
N1 --> C1
|
||||||
|
N2[StateNode₂] --> N1
|
||||||
|
N2 --> S
|
||||||
|
N2 --> C2[content₂]
|
||||||
|
END[StateNode __end__] --> N2
|
||||||
|
END --> S
|
||||||
|
```
|
||||||
|
|
||||||
|
## CasStore Interface
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type CasStore = {
|
||||||
|
put(content: string): Promise<string>; // returns hash
|
||||||
|
get(hash: string): Promise<string | null>;
|
||||||
|
delete(hash: string): Promise<void>;
|
||||||
|
list(): Promise<string[]>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`put` normalizes raw strings into `content` Merkle nodes before hashing; pre-serialized RFC v3 nodes pass through unchanged.
|
||||||
|
|
||||||
|
## Garbage Collection
|
||||||
|
|
||||||
|
`cas gc` performs a mark-and-sweep over all CAS blobs. It seeds the reachable set from `head` and `start` hashes in every `threads.json` and `history/*.jsonl`, then traverses `refs` edges transitively. Unreachable blobs are deleted. The result reports `scannedThreads`, `activeRefs`, and `deletedEntries`.
|
||||||
|
|
||||||
|
## Code Pointers
|
||||||
|
|
||||||
|
| Package | File | What it does |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `@uncaged/workflow-protocol` | `src/types.ts` | `CasStore` interface definition |
|
||||||
|
| `@uncaged/workflow-protocol` | `src/cas-types.ts` | `StartNode`, `StateNode`, `ContentMerkleNode` types |
|
||||||
|
| `@uncaged/workflow-cas` | `src/cas.ts` | `createCasStore` — filesystem implementation |
|
||||||
|
| `@uncaged/workflow-cas` | `src/hash.ts` | `hashString`, `hashWorkflowBundleBytes` — XXH64 + Crockford |
|
||||||
|
| `@uncaged/workflow-cas` | `src/nodes.ts` | `putStartNode`, `putStateNode`, `putContentNodeWithRefs`, `parseCasThreadNode` |
|
||||||
|
| `@uncaged/workflow-cas` | `src/merkle.ts` | `parseMerkleNode`, `serializeMerkleNode`, `getContentMerklePayload` |
|
||||||
|
| `@uncaged/workflow-cas` | `src/reachable.ts` | Reachability traversal for GC |
|
||||||
|
| `@uncaged/workflow-execute` | `src/engine/gc.ts` | GC orchestration |
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Thread](./thread.md) — how thread execution state maps to CAS nodes
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
# CLI
|
||||||
|
|
||||||
|
> `uncaged-workflow` — the command-line tool for registering bundles, running threads, inspecting CAS, and connecting to the gateway.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The CLI (`@uncaged/cli-workflow`) is the primary human interface to the workflow engine. It is a multi-level command dispatcher: top-level command groups (`workflow`, `thread`, `cas`, `init`, `setup`) each have a set of subcommands. Two shortcuts (`run`, `live`) alias frequently-used subcommands.
|
||||||
|
|
||||||
|
The storage root defaults to `~/.uncaged/workflow` and can be overridden with `WORKFLOW_STORAGE_ROOT` or `UNCAGED_WORKFLOW_STORAGE_ROOT` environment variables.
|
||||||
|
|
||||||
|
## Command Reference
|
||||||
|
|
||||||
|
### Workflow Registry (`workflow`)
|
||||||
|
|
||||||
|
| Subcommand | Args | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `workflow add` | `<name> <file.esm.js> [--types <path>]` | Register a workflow bundle in the registry |
|
||||||
|
| `workflow list` | | List all registered workflows |
|
||||||
|
| `workflow show` | `<name>` | Show bundle hash, timestamp, and descriptor |
|
||||||
|
| `workflow rm` | `<name>` | Remove a workflow from the registry |
|
||||||
|
| `workflow history` | `<name>` | Show version history for a workflow |
|
||||||
|
| `workflow rollback` | `<name> [hash]` | Roll back to a previous version |
|
||||||
|
|
||||||
|
### Thread Execution (`thread`)
|
||||||
|
|
||||||
|
| Subcommand | Args | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `thread run` | `<name> [--prompt <text>]` | Start a new thread for a workflow; prints thread ID |
|
||||||
|
| `thread list` | `[name]` | List threads, optionally filtered by workflow name |
|
||||||
|
| `thread show` | `<id>` | Show thread steps and state from CAS |
|
||||||
|
| `thread rm` | `<id>` | Remove a thread (from index and history) |
|
||||||
|
| `thread fork` | `<thread-id> [--from-role <role>]` | Fork from an existing thread |
|
||||||
|
| `thread ps` | | List running (active) threads |
|
||||||
|
| `thread kill` | `<thread-id>` | Send kill signal to a running thread |
|
||||||
|
| `thread live` | `<thread-id> \| --latest [--debug] [--role <name>]` | Attach and stream output live |
|
||||||
|
| `thread pause` | `<thread-id>` | Pause a running thread |
|
||||||
|
| `thread resume` | `<thread-id>` | Resume a paused thread |
|
||||||
|
|
||||||
|
### CAS Inspection (`cas`)
|
||||||
|
|
||||||
|
| Subcommand | Args | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `cas get` | `<hash>` | Print a CAS blob by hash |
|
||||||
|
| `cas put` | `<content>` | Store content in CAS, print hash |
|
||||||
|
| `cas list` | | List all hashes in CAS |
|
||||||
|
| `cas rm` | `<hash>` | Remove a CAS entry |
|
||||||
|
| `cas gc` | | Garbage-collect unreferenced entries |
|
||||||
|
|
||||||
|
### Other Commands
|
||||||
|
|
||||||
|
| Command | Args | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `run <name> [...]` | | Shortcut for `thread run` |
|
||||||
|
| `live <id> [...]` | | Shortcut for `thread live` |
|
||||||
|
| `init` | | Scaffold a workflow workspace |
|
||||||
|
| `setup` | | Configure LLM providers in `workflow.yaml` |
|
||||||
|
| `connect [--name NAME] [--gateway URL]` | | Connect to gateway via WebSocket |
|
||||||
|
| `skill [topic]` | | Print agent-consumable docs (`cli`, `develop`, `author`) |
|
||||||
|
|
||||||
|
## Common Usage Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Register a bundle
|
||||||
|
uncaged-workflow workflow add solve-issue dist/solve-issue.esm.js
|
||||||
|
|
||||||
|
# Run a workflow (prints thread ID)
|
||||||
|
uncaged-workflow run solve-issue --prompt "Fix the login bug in auth.ts"
|
||||||
|
|
||||||
|
# Watch live output
|
||||||
|
uncaged-workflow live <thread-id>
|
||||||
|
|
||||||
|
# Inspect a CAS blob
|
||||||
|
uncaged-workflow cas get 3TNKQRJ7BM4XH
|
||||||
|
|
||||||
|
# Show all running threads
|
||||||
|
uncaged-workflow thread ps
|
||||||
|
|
||||||
|
# Garbage-collect
|
||||||
|
uncaged-workflow cas gc
|
||||||
|
|
||||||
|
# Roll back to previous version
|
||||||
|
uncaged-workflow workflow rollback solve-issue
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `WORKFLOW_STORAGE_ROOT` | Override storage directory (default: `~/.uncaged/workflow`) |
|
||||||
|
| `UNCAGED_WORKFLOW_STORAGE_ROOT` | Internal override; takes priority over `WORKFLOW_STORAGE_ROOT` |
|
||||||
|
|
||||||
|
## Code Pointers
|
||||||
|
|
||||||
|
| Package | File | What it does |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `@uncaged/cli-workflow` | `src/cli-dispatch.ts` | Top-level command router (`COMMAND_TABLE`) |
|
||||||
|
| `@uncaged/cli-workflow` | `src/cli-usage.ts` | Usage text formatting |
|
||||||
|
| `@uncaged/cli-workflow` | `src/commands/workflow/dispatch.ts` | `WORKFLOW_SUBCOMMAND_TABLE` |
|
||||||
|
| `@uncaged/cli-workflow` | `src/commands/thread/dispatch.ts` | `THREAD_SUBCOMMAND_TABLE` |
|
||||||
|
| `@uncaged/cli-workflow` | `src/commands/cas/dispatch.ts` | `CAS_SUBCOMMAND_TABLE` |
|
||||||
|
| `@uncaged/cli-workflow` | `src/cli.ts` | CLI entry point |
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Bundle](./bundle.md) — what `workflow add` registers
|
||||||
|
- [Thread](./thread.md) — what `thread run` creates
|
||||||
|
- [Registry](./registry.md) — the `workflow.yaml` that `workflow` commands manage
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Dashboard
|
||||||
|
|
||||||
|
> A private React single-page application for browsing workflows, inspecting thread execution records, and triggering runs via a connected gateway.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The dashboard (`workflow-dashboard`) is a read-mostly web UI that surfaces thread history and workflow metadata. It is a private package (not published to npm) and is deployed separately from the CLI. It communicates with one or more remote workflow engine instances through the `workflow-gateway` WebSocket gateway, which proxies API calls back to each connected CLI client.
|
||||||
|
|
||||||
|
The dashboard is not required to use the workflow engine — it is an optional observability layer on top of the same data that the CLI exposes.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Concern | Choice |
|
||||||
|
|---------|--------|
|
||||||
|
| Framework | React (functional components, hooks) |
|
||||||
|
| Build | Vite |
|
||||||
|
| Styling | CSS variables via Tailwind-compatible utility classes |
|
||||||
|
| Charts/graphs | ReactFlow (workflow graph visualization) |
|
||||||
|
| HTTP | Native `fetch` with Bearer token auth |
|
||||||
|
| Transport | REST over HTTP (proxied through the gateway) |
|
||||||
|
|
||||||
|
## Data Sources
|
||||||
|
|
||||||
|
The dashboard consumes four REST endpoints per connected client (proxied by the gateway):
|
||||||
|
|
||||||
|
| Endpoint | Data |
|
||||||
|
|----------|------|
|
||||||
|
| `GET /workflows` | List of registered workflows with current hash and timestamp |
|
||||||
|
| `GET /workflows/:name` | Full workflow detail including `WorkflowDescriptor` and version history |
|
||||||
|
| `GET /threads` | All threads (active + completed) with summary fields |
|
||||||
|
| `GET /threads/:id` | Thread records: `ThreadStartRecord`, `RoleRecord[]`, `WorkflowResultRecord` |
|
||||||
|
|
||||||
|
The gateway multiplexes multiple CLI clients; the sidebar allows switching between them.
|
||||||
|
|
||||||
|
## Views
|
||||||
|
|
||||||
|
| View | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| **Workflows** | Lists all registered workflows; clicking shows hash, descriptor, role graph, and version history |
|
||||||
|
| **Threads** | Lists all threads; clicking shows the full step-by-step execution record with role metadata |
|
||||||
|
| **Run dialog** | Form to start a new thread by picking a workflow and entering a prompt |
|
||||||
|
|
||||||
|
### Workflow Graph
|
||||||
|
|
||||||
|
Each workflow's `WorkflowDescriptor.graph` is rendered as an interactive ReactFlow diagram. Nodes represent roles (plus `__start__` and `__end__` terminals); edges represent moderator transitions labeled with condition names.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
A Bearer token (stored in `localStorage` under `workflow-api-key`) is sent with every API request. The login page prompts for this key on first load. The gateway validates the token before proxying requests to connected clients.
|
||||||
|
|
||||||
|
## Gateway Connection
|
||||||
|
|
||||||
|
`uncaged-workflow connect [--name NAME] [--gateway URL]` registers the local workflow engine as a named client with the gateway over a WebSocket. The gateway then forwards REST API calls from the dashboard to the connected CLI process. The dashboard calls `GET /api/gateway/endpoints` to discover connected clients.
|
||||||
|
|
||||||
|
## Private App Status
|
||||||
|
|
||||||
|
`workflow-dashboard` has `"private": true` in its `package.json` and is excluded from the changeset versioning pipeline. It is developed alongside the engine packages but distributed separately (e.g., as a static build hosted alongside the gateway server).
|
||||||
|
|
||||||
|
## Code Pointers
|
||||||
|
|
||||||
|
| Package | File | What it does |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `workflow-dashboard` | `src/app.tsx` | Root component — routing, auth state, view switching |
|
||||||
|
| `workflow-dashboard` | `src/api.ts` | All API functions + endpoint types (`ThreadRecord`, `WorkflowDetail`, etc.) |
|
||||||
|
| `workflow-dashboard` | `src/components/thread-detail.tsx` | Thread step viewer |
|
||||||
|
| `workflow-dashboard` | `src/components/workflow-graph/workflow-graph.tsx` | ReactFlow graph of workflow roles and transitions |
|
||||||
|
| `workflow-dashboard` | `src/components/sidebar.tsx` | Client selector and view navigation |
|
||||||
|
| `@uncaged/workflow-gateway` | `src/index.ts` | Gateway server entry point |
|
||||||
|
| `@uncaged/workflow-gateway` | `src/ws-protocol.ts` | WebSocket message protocol between CLI and gateway |
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Thread](./thread.md) — the execution records the dashboard displays
|
||||||
|
- [Engine](./engine.md) — the process that produces those records
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Engine
|
||||||
|
|
||||||
|
> The execution loop that drives a workflow bundle's `AsyncGenerator`, persisting each yielded `RoleOutput` as a CAS `StateNode` and managing thread lifecycle.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The engine (`executeThread`) takes a `WorkflowFn` and runs it to completion. It is responsible for three concerns: persisting each role output to CAS, updating the active-thread index after every step, and terminating the thread cleanly when the generator finishes, is aborted, or is killed by the supervisor.
|
||||||
|
|
||||||
|
The engine does not interact with LLMs directly — that responsibility belongs to the workflow bundle's `run` function and its bound agent adapters. The engine only observes `RoleOutput` values yielded by the generator.
|
||||||
|
|
||||||
|
## Execution Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[executeThread] --> B[putStartNode → CAS]
|
||||||
|
B --> C[publishHead → threads.json]
|
||||||
|
C --> D{generator.next}
|
||||||
|
D -- done --> E[finalizeThread]
|
||||||
|
D -- yield RoleOutput --> F[appendStateForStep → CAS]
|
||||||
|
F --> G[publishHead → threads.json]
|
||||||
|
G --> H{supervisorInterval?}
|
||||||
|
H -- kill --> E
|
||||||
|
H -- continue --> I{awaitAfterEachYield}
|
||||||
|
I --> D
|
||||||
|
D -- AbortSignal --> J[finalizeAbortedThread]
|
||||||
|
E --> K[removeThreadEntry]
|
||||||
|
K --> L[appendThreadHistoryEntry]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Role Loop (inside the bundle's `createWorkflow`)
|
||||||
|
|
||||||
|
The `WorkflowFn` produced by `createWorkflow` runs its own loop — one iteration per role step:
|
||||||
|
|
||||||
|
1. **Moderator**: calls `pickNext(ctx)` (derived from the `ModeratorTable`) → returns a role name or `END`.
|
||||||
|
2. **Adapter**: calls the bound `AdapterFn` with the role's `systemPrompt` and Zod schema → returns `RoleFn` → executes → returns `RoleResult<T>`.
|
||||||
|
3. **Persist**: calls `putContentNodeWithRefs` to store the role output in CAS, constructs a `RoleStep`, and `yield`s a `RoleOutput` to the engine.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant E as Engine
|
||||||
|
participant W as WorkflowFn (bundle)
|
||||||
|
participant M as Moderator
|
||||||
|
participant A as AdapterFn
|
||||||
|
participant C as CAS
|
||||||
|
|
||||||
|
E->>W: generator.next()
|
||||||
|
W->>M: pickNext(ctx) → roleName
|
||||||
|
W->>A: adapter(systemPrompt, schema)(ctx, runtime)
|
||||||
|
A-->>W: RoleResult { meta, childThread }
|
||||||
|
W->>C: putContentNodeWithRefs(JSON.stringify(meta))
|
||||||
|
W-->>E: yield RoleOutput
|
||||||
|
E->>C: putStateNode(StateNodePayload)
|
||||||
|
E->>E: publishHead(threads.json)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Engine input
|
||||||
|
type ExecuteThreadOptions = {
|
||||||
|
depth: number;
|
||||||
|
parentStateHash: string | null;
|
||||||
|
signal: AbortSignal;
|
||||||
|
awaitAfterEachYield: () => Promise<void>; // used for pause/resume gate
|
||||||
|
forkContinuation: ForkContinuationOptions | null;
|
||||||
|
prefilledDiskSteps: PrefilledDiskStep[] | null;
|
||||||
|
replayTimestamps: readonly number[] | null;
|
||||||
|
storageRoot: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Engine output
|
||||||
|
type WorkflowResult = {
|
||||||
|
returnCode: number;
|
||||||
|
summary: string;
|
||||||
|
rootHash: string; // hash of the __end__ StateNode
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pause Gate
|
||||||
|
|
||||||
|
`awaitAfterEachYield` is a function injected by the worker/runner that can block the loop between steps. The `ThreadPauseGate` in `thread-pause-gate.ts` provides `pause()` / `resume()` operations that control this gate. When paused, the loop suspends after writing the current step but before requesting the next one.
|
||||||
|
|
||||||
|
## Supervisor
|
||||||
|
|
||||||
|
If `workflowConfig.supervisorInterval > 0`, the engine runs a supervisor check after every `supervisorInterval` steps. The supervisor calls an LLM with a summary of recent steps and returns `"continue"` or `"kill"`. A `"kill"` decision finalizes the thread immediately with `returnCode: 1` and a summary string.
|
||||||
|
|
||||||
|
## Summarizer
|
||||||
|
|
||||||
|
On normal completion (generator returns), the engine calls `createSummarizer` to produce a single LLM-generated summary string from recent step content. This summary replaces the bundle's raw `WorkflowCompletion.summary` in the final history record.
|
||||||
|
|
||||||
|
## Code Pointers
|
||||||
|
|
||||||
|
| Package | File | What it does |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `@uncaged/workflow-execute` | `src/engine/engine.ts` | `executeThread` — main engine entry point |
|
||||||
|
| `@uncaged/workflow-execute` | `src/engine/types.ts` | `ExecuteThreadOptions`, `ExecuteThreadIo`, `ChainState`, `ThreadPauseGate` |
|
||||||
|
| `@uncaged/workflow-execute` | `src/engine/threads-index.ts` | `threads.json` persistence, history append |
|
||||||
|
| `@uncaged/workflow-execute` | `src/engine/supervisor.ts` | Supervisor LLM check (`"continue"` / `"kill"`) |
|
||||||
|
| `@uncaged/workflow-execute` | `src/engine/summarizer.ts` | Post-completion LLM summary |
|
||||||
|
| `@uncaged/workflow-execute` | `src/engine/thread-pause-gate.ts` | Pause/resume gate |
|
||||||
|
| `@uncaged/workflow-execute` | `src/engine/worker.ts` | Worker-process entry that spawns `executeThread` in a subprocess |
|
||||||
|
| `@uncaged/workflow-runtime` | `src/create-workflow.ts` | `createWorkflow` — the role loop inside the bundle |
|
||||||
|
| `@uncaged/workflow-protocol` | `src/types.ts` | `WorkflowFn`, `RoleOutput`, `WorkflowCompletion`, `AdvanceOutcome` |
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Role](./role.md) — what the moderator selects each iteration
|
||||||
|
- [Agent Binding](./agent-binding.md) — what executes a role and returns its output
|
||||||
|
- [Reactor](./reactor.md) — used internally for the extract and supervisor LLM calls
|
||||||
|
- [Thread](./thread.md) — the CAS-persisted result of running the engine
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Package Map
|
||||||
|
|
||||||
|
> All packages in the monorepo with their responsibilities, dependency layers, and publication status.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The monorepo is organized as a strict dependency DAG. Each layer may only depend on layers below it. The execution stack flows from the shared protocol types at the bottom up to the CLI at the top. Agent packages and template packages are leaf nodes that depend on the runtime layer but are not depended upon by the core stack.
|
||||||
|
|
||||||
|
## Package List
|
||||||
|
|
||||||
|
| Package | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `@uncaged/workflow-protocol` | Shared types (`ThreadContext`, `RoleDefinition`, `CasStore`, `Result`, etc.) and constants (`START`, `END`) |
|
||||||
|
| `@uncaged/workflow-runtime` | `createWorkflow`, type re-exports; primary dependency for bundle authors |
|
||||||
|
| `@uncaged/workflow-util` | Utilities: Crockford Base32, ULID, structured logger, storage paths |
|
||||||
|
| `@uncaged/workflow-reactor` | `createThreadReactor` (ReAct loop), `createLlmFn` (OpenAI-compatible LLM caller) |
|
||||||
|
| `@uncaged/workflow-cas` | `createCasStore` (filesystem CAS), XXH64 hashing, Merkle node serialization |
|
||||||
|
| `@uncaged/workflow-register` | Bundle validation, `workflow.yaml` registry read/write, model resolution |
|
||||||
|
| `@uncaged/workflow-execute` | Engine (`executeThread`), extract phase, fork, GC, `workflowAsAgent` |
|
||||||
|
| `@uncaged/cli-workflow` | `uncaged-workflow` CLI — command dispatcher for all user-facing operations |
|
||||||
|
| `@uncaged/workflow-agent-cursor` | Adapter that runs the `cursor` CLI non-interactively in a workspace |
|
||||||
|
| `@uncaged/workflow-agent-hermes` | Adapter that runs `hermes chat` (Nerve-style CLI agent) |
|
||||||
|
| `@uncaged/workflow-agent-llm` | Adapter for direct LLM chat completions |
|
||||||
|
| `@uncaged/workflow-agent-react` | Adapter with ReAct loop and file/shell tools |
|
||||||
|
| `@uncaged/workflow-util-agent` | Shared agent utilities: `buildThreadInput`, `spawnCli`, `createAgentAdapter` |
|
||||||
|
| `@uncaged/workflow-template-develop` | `develop` workflow template (planner → coder → reviewer → tester → committer) |
|
||||||
|
| `@uncaged/workflow-template-solve-issue` | `solve-issue` workflow template (preparer → developer → submitter) |
|
||||||
|
| `@uncaged/workflow-gateway` | WebSocket gateway for remote CLI-to-dashboard communication |
|
||||||
|
| `workflow-dashboard` | React dashboard (private, unpublished) — thread/workflow viewer |
|
||||||
|
|
||||||
|
## Dependency Layer Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph Layer 0 — Protocol
|
||||||
|
P[workflow-protocol]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Layer 1 — Foundations
|
||||||
|
RT[workflow-runtime]
|
||||||
|
UT[workflow-util]
|
||||||
|
RX[workflow-reactor]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Layer 2 — Storage & Register
|
||||||
|
CAS[workflow-cas]
|
||||||
|
REG[workflow-register]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Layer 3 — Execute
|
||||||
|
EX[workflow-execute]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Layer 4 — CLI
|
||||||
|
CLI[cli-workflow]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Agents (leaf)
|
||||||
|
AGC[workflow-agent-cursor]
|
||||||
|
AGH[workflow-agent-hermes]
|
||||||
|
AGL[workflow-agent-llm]
|
||||||
|
AGR[workflow-agent-react]
|
||||||
|
UA[workflow-util-agent]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Templates (leaf)
|
||||||
|
TD[workflow-template-develop]
|
||||||
|
TS[workflow-template-solve-issue]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Dashboard
|
||||||
|
GW[workflow-gateway]
|
||||||
|
DB[workflow-dashboard]
|
||||||
|
end
|
||||||
|
|
||||||
|
RT --> P
|
||||||
|
UT --> P
|
||||||
|
RX --> P
|
||||||
|
CAS --> P
|
||||||
|
REG --> P
|
||||||
|
REG --> UT
|
||||||
|
EX --> RT
|
||||||
|
EX --> UT
|
||||||
|
EX --> CAS
|
||||||
|
EX --> REG
|
||||||
|
EX --> RX
|
||||||
|
CLI --> EX
|
||||||
|
CLI --> UT
|
||||||
|
CLI --> REG
|
||||||
|
AGC --> RT
|
||||||
|
AGC --> UT
|
||||||
|
AGC --> UA
|
||||||
|
AGH --> RT
|
||||||
|
AGH --> UA
|
||||||
|
AGL --> RT
|
||||||
|
AGR --> RT
|
||||||
|
AGR --> RX
|
||||||
|
UA --> RT
|
||||||
|
TD --> RT
|
||||||
|
TS --> RT
|
||||||
|
DB --> GW
|
||||||
|
```
|
||||||
|
|
||||||
|
## Published vs. Private
|
||||||
|
|
||||||
|
All `@uncaged/*` packages are published to **npmjs.org** under a fixed versioning scheme (all packages share the same version number via `@changesets/cli` in fixed mode).
|
||||||
|
|
||||||
|
| Status | Packages |
|
||||||
|
|--------|---------|
|
||||||
|
| **Published** | All packages with `@uncaged/` scope |
|
||||||
|
| **Private** | `workflow-dashboard` (no `@uncaged/` scope, `"private": true`) |
|
||||||
|
|
||||||
|
## Code Pointers
|
||||||
|
|
||||||
|
| Package | File | What it does |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `@uncaged/workflow-protocol` | `src/types.ts` | Root type definitions for the entire stack |
|
||||||
|
| `@uncaged/workflow-runtime` | `src/index.ts` | Public API for bundle authors |
|
||||||
|
| `@uncaged/workflow-util` | `src/index.ts` | Utility re-exports |
|
||||||
|
| `@uncaged/workflow-execute` | `src/index.ts` | Engine public API |
|
||||||
|
| `@uncaged/cli-workflow` | `src/cli-dispatch.ts` | Top-level command table |
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Bundle](./bundle.md) — produced by workspace authors using `@uncaged/workflow-runtime`
|
||||||
|
- [Engine](./engine.md) — the core of `@uncaged/workflow-execute`
|
||||||
|
- [Reactor](./reactor.md) — `@uncaged/workflow-reactor`
|
||||||
|
- [Registry](./registry.md) — `@uncaged/workflow-register`
|
||||||
|
- [CLI](./cli.md) — `@uncaged/cli-workflow`
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Reactor
|
||||||
|
|
||||||
|
> A generic ReAct (Reason + Act) loop that drives an LLM through multiple tool-call rounds until it produces structured output matching a Zod schema.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The reactor is a reusable abstraction for LLM interactions that require tool use. It runs a multi-turn conversation loop: the LLM is presented with a user message and a set of tools, and responds either with a tool call (which the reactor dispatches and feeds back) or with a plain JSON object matching the expected schema. The loop repeats until structured output is obtained or `maxRounds` is exhausted.
|
||||||
|
|
||||||
|
The reactor is used in two places:
|
||||||
|
|
||||||
|
1. **Extract phase** — `createExtract` in `@uncaged/workflow-execute` uses a CAS-backed reactor to extract typed `meta` from a role's content hash.
|
||||||
|
2. **React agent** — `createReactAdapter` in `@uncaged/workflow-agent-react` uses the reactor as its execution backbone.
|
||||||
|
|
||||||
|
## createThreadReactor
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function createThreadReactor<TThread>(
|
||||||
|
config: ThreadReactorConfig<TThread>,
|
||||||
|
): ThreadReactorFn<TThread>
|
||||||
|
```
|
||||||
|
|
||||||
|
`ThreadReactorConfig` bundles:
|
||||||
|
|
||||||
|
| Field | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `llm` | The `LlmFn` to call each round |
|
||||||
|
| `staticTools` | Tools always available (e.g., `cas_get`) |
|
||||||
|
| `structuredToolFromSchema` | Derives a schema-specific extraction tool from the Zod schema |
|
||||||
|
| `systemPromptForStructuredTool` | Constructs the system prompt given the extraction tool name |
|
||||||
|
| `toolHandler` | Handles non-structured tool calls; receives the raw `ToolCall` and thread context |
|
||||||
|
| `maxRounds` | Hard stop after N rounds; returns `err("max_react_rounds_exceeded")` |
|
||||||
|
|
||||||
|
## Round Lifecycle
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant R as Reactor
|
||||||
|
participant L as LLM
|
||||||
|
participant H as toolHandler
|
||||||
|
|
||||||
|
R->>L: messages + tools
|
||||||
|
L-->>R: response
|
||||||
|
|
||||||
|
alt plain JSON (valid schema)
|
||||||
|
R-->>R: return ok(value)
|
||||||
|
else plain JSON (invalid)
|
||||||
|
R->>L: correction message
|
||||||
|
else tool_calls
|
||||||
|
loop each call
|
||||||
|
alt structured tool
|
||||||
|
R-->>R: validate args → return ok(value)
|
||||||
|
else static tool
|
||||||
|
R->>H: toolHandler(call, thread)
|
||||||
|
H-->>R: content string
|
||||||
|
R->>L: tool result message
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## LlmFn
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type LlmFn = (input: {
|
||||||
|
messages: ChatMessage[];
|
||||||
|
tools: readonly ToolDefinition[];
|
||||||
|
}) => Promise<Result<string, string>>;
|
||||||
|
```
|
||||||
|
|
||||||
|
`createLlmFn(provider)` in `@uncaged/workflow-reactor` builds an `LlmFn` that calls the OpenAI-compatible chat completions endpoint and returns the raw response body as a string for the reactor to parse.
|
||||||
|
|
||||||
|
## Extract Phase
|
||||||
|
|
||||||
|
`createExtract(provider, { cas })` in `@uncaged/workflow-execute` creates a `CasReactor` — a preconfigured `ThreadReactorFn` with a `cas_get` static tool. The extract function loads the content payload for a given hash, sends it to the reactor with the role's Zod schema, and returns `ExtractResult<T>`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ExtractFn = <T extends Record<string, unknown>>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
contentHash: string,
|
||||||
|
) => Promise<ExtractResult<T>>;
|
||||||
|
```
|
||||||
|
|
||||||
|
The `cas_get` tool allows the LLM to dereference CAS hashes during extraction — important when the content node references artifact hashes.
|
||||||
|
|
||||||
|
## Relationship to Engine
|
||||||
|
|
||||||
|
The reactor is called within `AdapterFn` implementations (e.g., `createLlmAdapter`, `createReactAdapter`) when the agent needs multi-turn tool interaction to complete a role. The engine itself does not call the reactor directly — it only drives the outer `WorkflowFn` generator and persists `RoleOutput` values.
|
||||||
|
|
||||||
|
## Code Pointers
|
||||||
|
|
||||||
|
| Package | File | What it does |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `@uncaged/workflow-reactor` | `src/thread-reactor.ts` | `createThreadReactor` — generic ReAct loop |
|
||||||
|
| `@uncaged/workflow-reactor` | `src/llm-fn.ts` | `createLlmFn` — OpenAI-compatible LLM caller |
|
||||||
|
| `@uncaged/workflow-reactor` | `src/types.ts` | `LlmFn`, `ThreadReactorConfig`, `ToolCall`, `ToolDefinition`, `ChatMessage` |
|
||||||
|
| `@uncaged/workflow-execute` | `src/cas-reactor.ts` | `createCasReactor` — reactor with `cas_get` static tool |
|
||||||
|
| `@uncaged/workflow-execute` | `src/extract/extract-fn.ts` | `createExtract` — extract phase using the CAS reactor |
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Engine](./engine.md) — drives the workflow generator; extract is called inside the adapter layer
|
||||||
|
- [Agent Binding](./agent-binding.md) — adapter implementations that use the reactor internally
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Registry
|
||||||
|
|
||||||
|
> `workflow.yaml` — the local file that maps workflow names to their current and historical bundle hashes, plus global LLM provider configuration.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The registry is a single YAML file at `<storageRoot>/workflow.yaml` (default: `~/.uncaged/workflow/workflow.yaml`). It is the authoritative index of which bundles are available on a machine and what name each one is known by. All CLI workflow commands read or write this file.
|
||||||
|
|
||||||
|
The registry is read on every `uncaged-workflow run` invocation to look up the bundle hash for a given name, then used again to resolve the `extract` model configuration. It is written atomically via the `writeWorkflowRegistry` function.
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
config:
|
||||||
|
maxDepth: 3
|
||||||
|
supervisorInterval: 5
|
||||||
|
providers:
|
||||||
|
openrouter:
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1"
|
||||||
|
apiKey: "sk-or-..."
|
||||||
|
models:
|
||||||
|
extract: "openrouter/anthropic/claude-sonnet-4-5"
|
||||||
|
supervisor: "openrouter/anthropic/claude-haiku-3-5"
|
||||||
|
|
||||||
|
workflows:
|
||||||
|
solve-issue:
|
||||||
|
hash: "3TNKQRJ7BM4XH"
|
||||||
|
timestamp: 1716000000000
|
||||||
|
history:
|
||||||
|
- hash: "2BMJPQ6YAK3WG"
|
||||||
|
timestamp: 1715000000000
|
||||||
|
develop:
|
||||||
|
hash: "7VQWX8NRHK1ZT"
|
||||||
|
timestamp: 1716100000000
|
||||||
|
history: []
|
||||||
|
```
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type WorkflowRegistryFile = {
|
||||||
|
config: WorkflowConfig | null;
|
||||||
|
workflows: Record<string, WorkflowRegistryEntry>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkflowRegistryEntry = {
|
||||||
|
hash: string; // current bundle hash (13-char Crockford Base32)
|
||||||
|
timestamp: number; // Unix epoch ms when this version was registered
|
||||||
|
history: WorkflowHistoryEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkflowHistoryEntry = {
|
||||||
|
hash: string;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bundle Registration Flow
|
||||||
|
|
||||||
|
1. `uncaged-workflow workflow add <name> <file.esm.js>` is called.
|
||||||
|
2. The bundle bytes are hashed with XXH64 → 13-char Crockford Base32.
|
||||||
|
3. The bundle file is copied into `<storageRoot>/bundles/<hash>/` (if not already present).
|
||||||
|
4. `registerWorkflowVersion` prepends the current head to `history` and sets the new hash as head.
|
||||||
|
5. The updated registry is written back to `workflow.yaml`.
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
|
||||||
|
Every `workflow add` on an already-registered name pushes the previous hash into `history`. History is ordered most-recent-first. `workflow rollback <name> [hash]` swaps the specified history entry back to head (or defaults to `history[0]`).
|
||||||
|
|
||||||
|
## Model Resolution
|
||||||
|
|
||||||
|
The `config.models` section uses `provider/model` references (e.g., `"openrouter/anthropic/claude-sonnet-4-5"`). `resolveModel` splits the reference on the first `/`, looks up the provider in `config.providers`, and returns a `ResolvedModel` with `{ baseUrl, apiKey, model }`. This is used by the engine to configure the `extract` LLM.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/workflow-register/src/config/resolve-model.ts
|
||||||
|
export function resolveModel(
|
||||||
|
config: WorkflowConfig,
|
||||||
|
modelKey: string,
|
||||||
|
): Result<ResolvedModel, string>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Pointers
|
||||||
|
|
||||||
|
| Package | File | What it does |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `@uncaged/workflow-register` | `src/registry/registry.ts` | `readWorkflowRegistry`, `writeWorkflowRegistry`, `registerWorkflowVersion`, `rollbackWorkflowToHistoryHash` |
|
||||||
|
| `@uncaged/workflow-register` | `src/registry/types.ts` | `WorkflowRegistryFile`, `WorkflowRegistryEntry`, `WorkflowHistoryEntry` |
|
||||||
|
| `@uncaged/workflow-register` | `src/registry/registry-normalize.ts` | YAML normalization for the registry root |
|
||||||
|
| `@uncaged/workflow-register` | `src/config/resolve-model.ts` | `resolveModel` — splits `provider/model` refs |
|
||||||
|
| `@uncaged/workflow-register` | `src/bundle/extract-bundle-exports.ts` | Validates bundle exports before registration |
|
||||||
|
| `@uncaged/workflow-protocol` | `src/types.ts` | `WorkflowConfig`, `ProviderConfig`, `ResolvedModel` |
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Bundle](./bundle.md) — what is stored and indexed in the registry
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Role
|
||||||
|
|
||||||
|
> A named actor within a workflow defined entirely as pure data — a description, a system prompt, an extraction schema, and an optional refs extractor — with no embedded agent logic.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A role is a `RoleDefinition<Meta>` value: a plain TypeScript object that describes what an actor in the workflow does and how its output should be structured. Roles are authored in the template or bundle source and passed to `createWorkflow` as part of the `WorkflowDefinition`. They never hold a reference to an agent implementation.
|
||||||
|
|
||||||
|
This separation of concerns is deliberate. The same role definition can be executed by different agents (Cursor, Hermes, an LLM, a React loop) simply by changing the `AdapterBinding` passed to `createWorkflow`. Roles are also serialized into the `WorkflowDescriptor` for tooling like the dashboard.
|
||||||
|
|
||||||
|
## RoleDefinition Type
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type RoleDefinition<Meta extends Record<string, unknown>> = {
|
||||||
|
description: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
schema: z.ZodType<Meta>;
|
||||||
|
extractRefs: ((meta: Meta) => string[]) | null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `description` | Human-readable summary for tooling and the `WorkflowDescriptor` |
|
||||||
|
| `systemPrompt` | Passed to the adapter as the agent's persona/instruction for this role |
|
||||||
|
| `schema` | Zod v4 schema that defines the structured output (`Meta`) of the role |
|
||||||
|
| `extractRefs` | Optional function that extracts CAS hashes from `meta` to record as artifact refs |
|
||||||
|
|
||||||
|
## Schema and Extraction
|
||||||
|
|
||||||
|
Each role's `schema` is a Zod v4 type parameterized to the role's `Meta` type. When a role executes via an `AdapterFn`, the adapter is responsible for producing a value that satisfies this schema directly (the `AdapterFn` receives the schema and system prompt and returns a `RoleFn` that yields `RoleResult<T>`).
|
||||||
|
|
||||||
|
If `extractRefs` is non-null, the engine calls it on the completed `meta` to collect additional CAS hashes that should appear in the `StateNode.refs` skip-list, enabling traversal of artifacts produced by the role.
|
||||||
|
|
||||||
|
## WorkflowDefinition
|
||||||
|
|
||||||
|
Roles are collected into a `WorkflowDefinition<M>` alongside the moderator table:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type WorkflowDefinition<M extends RoleMeta> = {
|
||||||
|
description: string;
|
||||||
|
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
|
||||||
|
table: ModeratorTable<M>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`M` is the `RoleMeta` map that binds each role name to its concrete `Meta` type. This gives full TypeScript type safety across the moderator, adapter, and CAS storage layers.
|
||||||
|
|
||||||
|
## WorkflowRoleDescriptor (Serialized)
|
||||||
|
|
||||||
|
The `WorkflowDescriptor` (stored in the bundle's `descriptor` export) contains a `roles` map of `WorkflowRoleDescriptor` objects — a JSON-serializable projection of each `RoleDefinition`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type WorkflowRoleDescriptor = {
|
||||||
|
description: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
schema: WorkflowRoleSchema; // JSON-compatible schema shape
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Pointers
|
||||||
|
|
||||||
|
| Package | File | What it does |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `@uncaged/workflow-protocol` | `src/types.ts` | `RoleDefinition`, `WorkflowDefinition`, `RoleMeta`, `WorkflowRoleDescriptor`, `WorkflowDescriptor` |
|
||||||
|
| `@uncaged/workflow-runtime` | `src/create-workflow.ts` | Consumes `WorkflowDefinition` roles in the adapter dispatch loop |
|
||||||
|
| `@uncaged/workflow-register` | `src/bundle/build-descriptor.ts` | Serializes `RoleDefinition[]` to `WorkflowDescriptor` |
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Engine](./engine.md) — the loop that selects and executes roles
|
||||||
|
- [Agent Binding](./agent-binding.md) — the runtime binding that executes a role via a concrete agent
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Thread
|
||||||
|
|
||||||
|
> A single execution instance of a workflow, identified by a ULID, whose state is stored as a linked chain of immutable CAS nodes.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A thread is the runtime envelope around one call to a workflow's `run` function. It carries a unique ULID (26-char Crockford Base32) and tracks the full sequence of role steps that have executed. Because all state is written to CAS as immutable blobs, threads are append-only and fully auditable.
|
||||||
|
|
||||||
|
Every thread belongs to a specific workflow bundle (identified by hash). The engine writes a `StartNode` when the thread begins and one `StateNode` per completed role step — including a final `__end__` state on completion or abort. Steps accumulate in `ThreadContext.steps` and are replayed into the context whenever a thread is resumed.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Active: thread run / fork
|
||||||
|
Active --> Active: role step yielded
|
||||||
|
Active --> Paused: pause signal
|
||||||
|
Paused --> Active: resume signal
|
||||||
|
Active --> Completed: generator returns WorkflowCompletion
|
||||||
|
Active --> Aborted: kill signal / AbortSignal
|
||||||
|
Completed --> [*]: entry in history/*.jsonl
|
||||||
|
Aborted --> [*]: entry in history/*.jsonl (returnCode=130)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
Thread IDs are ULIDs: 26-char Crockford Base32 strings composed of a 10-char timestamp prefix and a 16-char random suffix. Generated by `generateUlid` from `@uncaged/workflow-util`.
|
||||||
|
|
||||||
|
## State Storage
|
||||||
|
|
||||||
|
Thread state is stored entirely in CAS as a linked list of nodes:
|
||||||
|
|
||||||
|
```
|
||||||
|
StartNode (type: "start")
|
||||||
|
payload: { name, hash, depth, parentState }
|
||||||
|
refs: [promptHash, parentState?]
|
||||||
|
|
||||||
|
StateNode (type: "state") ← one per role step
|
||||||
|
payload: { role, meta, start, content, ancestors[], compact, timestamp, childThread }
|
||||||
|
refs: [contentHash, startHash, ancestor hashes...]
|
||||||
|
|
||||||
|
StateNode (type: "state", role: "__end__") ← final node
|
||||||
|
payload: { returnCode, summary }
|
||||||
|
```
|
||||||
|
|
||||||
|
The `ancestors` array implements a skip-list (capped at 11 entries: 1 direct parent + up to 10 ancestors) to allow efficient traversal without loading every node in the chain.
|
||||||
|
|
||||||
|
## Index Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `<bundleDir>/threads.json` | Active thread index — maps `threadId → { head, start, updatedAt }` |
|
||||||
|
| `<bundleDir>/history/YYYY-MM-DD.jsonl` | Completed thread records — one JSON line per completed/aborted thread |
|
||||||
|
| `<storageRoot>/cas/` | All CAS blobs shared across all bundles |
|
||||||
|
|
||||||
|
A thread is "active" while it appears in `threads.json`. On completion, its entry is removed from `threads.json` and a record appended to the appropriate `history/*.jsonl` file.
|
||||||
|
|
||||||
|
## ThreadContext
|
||||||
|
|
||||||
|
The `ThreadContext` type is the read-only view passed into every role and moderator call:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ThreadContext<M extends RoleMeta = RoleMeta> = {
|
||||||
|
threadId: string;
|
||||||
|
depth: number;
|
||||||
|
bundleHash: string;
|
||||||
|
start: StartStep;
|
||||||
|
steps: RoleStep<M>[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`depth` tracks nesting for sub-workflow invocations (workflow-as-agent). `steps` grows by one entry after each successful role execution.
|
||||||
|
|
||||||
|
## Fork
|
||||||
|
|
||||||
|
A thread can be forked from any completed role step via `thread fork <id> [--from-role <role>]`. The fork reuses the original `StartNode` (same `startHash`) and replays CAS steps up to the fork point before resuming the generator. The forked thread gets a new ULID.
|
||||||
|
|
||||||
|
## Debug Logs
|
||||||
|
|
||||||
|
Each thread writes structured JSONL debug logs to `.info.jsonl` in the bundle directory. Each log line is `{ tag, content, timestamp }` where `tag` is an 8-char Crockford Base32 call-site identifier.
|
||||||
|
|
||||||
|
## Code Pointers
|
||||||
|
|
||||||
|
| Package | File | What it does |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `@uncaged/workflow-protocol` | `src/types.ts` | `ThreadContext`, `StartStep`, `RoleStep`, `RoleMeta` types |
|
||||||
|
| `@uncaged/workflow-protocol` | `src/cas-types.ts` | `StartNode`, `StartNodePayload`, `StateNode`, `StateNodePayload` |
|
||||||
|
| `@uncaged/workflow-execute` | `src/engine/threads-index.ts` | `threads.json` read/write, history append, `ThreadIndexEntry` |
|
||||||
|
| `@uncaged/workflow-execute` | `src/engine/engine.ts` | `executeThread` — starts, drives, and finalizes a thread |
|
||||||
|
| `@uncaged/workflow-execute` | `src/engine/fork-thread.ts` | Fork logic |
|
||||||
|
| `@uncaged/workflow-util` | `src/ulid.ts` | `generateUlid` — ULID generation |
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [CAS](./cas.md) — the storage layer that holds all thread state nodes
|
||||||
|
- [Engine](./engine.md) — the execution loop that drives the thread
|
||||||
|
- [Bundle](./bundle.md) — the workflow being executed in this thread
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Workflow Templates
|
||||||
|
|
||||||
|
> Pre-built `WorkflowDefinition` objects exported from `@uncaged/workflow-template-*` packages that bundle authors can import, customize, or use directly.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Templates are the reference implementations of common workflow patterns. They export a complete `WorkflowDefinition<M>` — typed roles with Zod schemas, and a `ModeratorTable` — ready to be passed to `createWorkflow`. A bundle author imports a template definition, supplies an `AdapterBinding`, calls `createWorkflow`, and exports the result as `run`.
|
||||||
|
|
||||||
|
Templates are published as regular `@uncaged/*` npm packages. They are not bundles themselves; they are TypeScript libraries that become part of a bundle when the author's workspace is built.
|
||||||
|
|
||||||
|
## solve-issue Template
|
||||||
|
|
||||||
|
**Package**: `@uncaged/workflow-template-solve-issue`
|
||||||
|
|
||||||
|
Resolves an issue end-to-end by preparing the repository, delegating implementation to a nested `develop` workflow, and opening a pull request.
|
||||||
|
|
||||||
|
### Roles
|
||||||
|
|
||||||
|
| Role | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `preparer` | Reads the issue, clones/checks out the repo, sets up the environment |
|
||||||
|
| `developer` | Delegates to the `develop` workflow via `workflowAsAgent` (child thread) |
|
||||||
|
| `submitter` | Opens a pull request with the completed changes |
|
||||||
|
|
||||||
|
### Moderator Table
|
||||||
|
|
||||||
|
```
|
||||||
|
__start__ → preparer → developer → submitter → __end__
|
||||||
|
```
|
||||||
|
|
||||||
|
Linear routing — each role runs exactly once in sequence.
|
||||||
|
|
||||||
|
### Meta Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type SolveIssueMeta = {
|
||||||
|
preparer: PreparerMeta;
|
||||||
|
developer: DeveloperMeta;
|
||||||
|
submitter: SubmitterMeta;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## develop Template
|
||||||
|
|
||||||
|
**Package**: `@uncaged/workflow-template-develop`
|
||||||
|
|
||||||
|
Plans an implementation in phases, codes each phase incrementally, reviews, verifies with tests/build/lint, and commits.
|
||||||
|
|
||||||
|
### Roles
|
||||||
|
|
||||||
|
| Role | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `planner` | Produces an ordered list of implementation phases with hashes |
|
||||||
|
| `coder` | Implements one phase; reports `completedPhase` hash in meta |
|
||||||
|
| `reviewer` | Reviews the accumulated changes; approves or requests changes |
|
||||||
|
| `tester` | Runs tests/lint/build; reports `passed` or `failed` |
|
||||||
|
| `committer` | Creates the final git commit |
|
||||||
|
|
||||||
|
### Moderator Table
|
||||||
|
|
||||||
|
```
|
||||||
|
__start__ → planner
|
||||||
|
planner → __end__ (if status == "aborted")
|
||||||
|
planner → coder (fallback)
|
||||||
|
coder → reviewer (if allPhasesComplete)
|
||||||
|
coder → coder (fallback — repeat per phase)
|
||||||
|
reviewer → tester (if status == "approved")
|
||||||
|
reviewer → coder (fallback — request changes)
|
||||||
|
tester → committer (if status == "passed")
|
||||||
|
tester → coder (fallback — fix failures)
|
||||||
|
committer → __end__
|
||||||
|
```
|
||||||
|
|
||||||
|
### Meta Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type DevelopMeta = {
|
||||||
|
planner: PlannerMeta;
|
||||||
|
coder: CoderMeta;
|
||||||
|
reviewer: ReviewerMeta;
|
||||||
|
tester: TesterMeta;
|
||||||
|
committer: CommitterMeta;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing a Custom Template
|
||||||
|
|
||||||
|
A minimal custom workflow:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createWorkflow, type WorkflowDefinition, END, START } from "@uncaged/workflow-runtime";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
import type { AdapterBinding } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
|
type MyMeta = {
|
||||||
|
analyst: { summary: string; confidence: number };
|
||||||
|
writer: { report: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const def: WorkflowDefinition<MyMeta> = {
|
||||||
|
description: "Analyse then write a report.",
|
||||||
|
roles: {
|
||||||
|
analyst: {
|
||||||
|
description: "Analyses the input and produces a structured summary.",
|
||||||
|
systemPrompt: "You are an expert analyst...",
|
||||||
|
schema: z.object({ summary: z.string(), confidence: z.number() }),
|
||||||
|
extractRefs: null,
|
||||||
|
},
|
||||||
|
writer: {
|
||||||
|
description: "Writes the final report.",
|
||||||
|
systemPrompt: "You are a technical writer...",
|
||||||
|
schema: z.object({ report: z.string() }),
|
||||||
|
extractRefs: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
[START]: [{ condition: "FALLBACK", role: "analyst" }],
|
||||||
|
analyst: [{ condition: "FALLBACK", role: "writer" }],
|
||||||
|
writer: [{ condition: "FALLBACK", role: END }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// In the bundle entry point:
|
||||||
|
export const run = createWorkflow(def, binding);
|
||||||
|
export const descriptor = buildDescriptor(def);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Template → Bundle Relationship
|
||||||
|
|
||||||
|
Templates are TypeScript library packages, not bundles. To use a template:
|
||||||
|
|
||||||
|
1. Install the template package from npm: `bun add @uncaged/workflow-template-develop`.
|
||||||
|
2. Import the definition: `import { developWorkflowDefinition } from "@uncaged/workflow-template-develop"`.
|
||||||
|
3. Supply an `AdapterBinding` and call `createWorkflow`.
|
||||||
|
4. Build with `bun build` to produce `.esm.js`.
|
||||||
|
5. Register with `uncaged-workflow workflow add`.
|
||||||
|
|
||||||
|
## Code Pointers
|
||||||
|
|
||||||
|
| Package | File | What it does |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `@uncaged/workflow-template-solve-issue` | `src/index.ts` | `solveIssueWorkflowDefinition`, role and moderator exports |
|
||||||
|
| `@uncaged/workflow-template-solve-issue` | `src/roles.ts` | `SolveIssueMeta`, `solveIssueRoles` |
|
||||||
|
| `@uncaged/workflow-template-solve-issue` | `src/moderator.ts` | `solveIssueTable` — linear transition table |
|
||||||
|
| `@uncaged/workflow-template-develop` | `src/index.ts` | `developWorkflowDefinition`, role and moderator exports |
|
||||||
|
| `@uncaged/workflow-template-develop` | `src/roles.ts` | `DevelopMeta`, `developRoles` |
|
||||||
|
| `@uncaged/workflow-template-develop` | `src/moderator.ts` | `developTable` — conditional multi-phase table |
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Bundle](./bundle.md) — the build artifact produced from a template + adapter
|
||||||
|
- [Role](./role.md) — the `RoleDefinition` type each template role implements
|
||||||
|
- [Engine](./engine.md) — the execution loop that drives the template's `WorkflowFn`
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@uncaged/workflow-util": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Replace optionalEnv/requireEnv with unified env(name, fallback) API
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@uncaged/workflow-protocol": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix: correct internal dependency versions for prerelease
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@uncaged/workflow-util-agent": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix: include create-agent-adapter.ts in published src
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@uncaged/workflow-protocol": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix: use npm publish with pinned deps instead of bun publish (workspace:^ resolution bug)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"mode": "pre",
|
||||||
|
"tag": "alpha",
|
||||||
|
"initialVersions": {
|
||||||
|
"@uncaged/cli-workflow": "0.4.5",
|
||||||
|
"@uncaged/workflow-agent-cursor": "0.4.5",
|
||||||
|
"@uncaged/workflow-agent-hermes": "0.4.5",
|
||||||
|
"@uncaged/workflow-agent-llm": "0.4.5",
|
||||||
|
"@uncaged/workflow-agent-react": "0.4.5",
|
||||||
|
"@uncaged/workflow-cas": "0.4.5",
|
||||||
|
"@uncaged/workflow-dashboard": "0.1.0",
|
||||||
|
"@uncaged/workflow-execute": "0.4.5",
|
||||||
|
"@uncaged/workflow-gateway": "0.4.5",
|
||||||
|
"@uncaged/workflow-protocol": "0.4.5",
|
||||||
|
"@uncaged/workflow-reactor": "0.4.5",
|
||||||
|
"@uncaged/workflow-register": "0.4.5",
|
||||||
|
"@uncaged/workflow-runtime": "0.4.5",
|
||||||
|
"@uncaged/workflow-template-develop": "0.4.5",
|
||||||
|
"@uncaged/workflow-template-solve-issue": "0.4.5",
|
||||||
|
"@uncaged/workflow-util": "0.4.5",
|
||||||
|
"@uncaged/workflow-util-agent": "0.4.5"
|
||||||
|
},
|
||||||
|
"changesets": [
|
||||||
|
"env-api-unify",
|
||||||
|
"fix-internal-deps",
|
||||||
|
"fix-publish-src",
|
||||||
|
"fix-workspace-deps",
|
||||||
|
"rfc-252-agent-fn"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@uncaged/workflow-protocol": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
feat: AgentFn<Opt> type boundary and createAgentAdapter bridging function (RFC #252)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Workflow Engine — Environment Variables
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Copy this file to .env and fill in the values.
|
||||||
|
|
||||||
|
# ── Cursor Agent ──
|
||||||
|
|
||||||
|
# CLI command to invoke the Cursor agent (required for develop workflow)
|
||||||
|
WORKFLOW_CURSOR_COMMAND=
|
||||||
|
|
||||||
|
# Model override for Cursor agent
|
||||||
|
WORKFLOW_CURSOR_MODEL=
|
||||||
|
|
||||||
|
# Timeout in milliseconds for Cursor agent operations
|
||||||
|
WORKFLOW_CURSOR_TIMEOUT=
|
||||||
|
|
||||||
|
# ── Hermes Agent (used by develop tester/committer + solve-issue) ──
|
||||||
|
|
||||||
|
# CLI command to invoke the Hermes agent (absolute path required)
|
||||||
|
WORKFLOW_HERMES_COMMAND=
|
||||||
|
|
||||||
|
# Model override for Hermes agent
|
||||||
|
WORKFLOW_HERMES_MODEL=
|
||||||
|
|
||||||
|
# Timeout in milliseconds for Hermes agent operations
|
||||||
|
WORKFLOW_HERMES_TIMEOUT=
|
||||||
|
|
||||||
|
# ── Storage ──
|
||||||
|
|
||||||
|
# Override the workflow storage root directory
|
||||||
|
# Default: ~/.uncaged/workflow
|
||||||
|
WORKFLOW_STORAGE_ROOT=
|
||||||
|
|
||||||
|
# Gateway secret for the serve command
|
||||||
|
WORKFLOW_DASHBOARD_SECRET=
|
||||||
|
|
||||||
|
# ── Display ──
|
||||||
|
|
||||||
|
# Set to any value to disable colored output
|
||||||
|
# NO_COLOR=1
|
||||||
+7
-3
@@ -1,6 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# pre-push hook: typecheck + biome + lint-log-tags
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
echo "🔍 pre-push: running checks..."
|
|
||||||
|
echo "🔍 Running check (tsc + biome + lint-log-tags)..."
|
||||||
bun run check
|
bun run check
|
||||||
echo "✅ pre-push: all checks passed"
|
|
||||||
|
echo "🧪 Running tests..."
|
||||||
|
bun run test
|
||||||
|
|
||||||
|
echo "✅ All checks passed!"
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ tsconfig.tsbuildinfo
|
|||||||
.npmrc
|
.npmrc
|
||||||
|
|
||||||
bunfig.toml
|
bunfig.toml
|
||||||
|
xiaoju/
|
||||||
|
solve-issue-entry.ts
|
||||||
|
packages/workflow-template-develop/develop.esm.js
|
||||||
|
|||||||
+7
-1
@@ -1,7 +1,13 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
|
"includes": [
|
||||||
|
"**",
|
||||||
|
"!**/dist",
|
||||||
|
"!**/node_modules",
|
||||||
|
"!packages/workflow/workflow",
|
||||||
|
"!xiaoju/scripts/bundle.ts"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const agent = createCursorAgent({
|
|||||||
command: "/home/azureuser/.local/bin/cursor-agent",
|
command: "/home/azureuser/.local/bin/cursor-agent",
|
||||||
model: "auto",
|
model: "auto",
|
||||||
timeout: 300_000,
|
timeout: 300_000,
|
||||||
|
workspace: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const descriptor = buildDevelopDescriptor();
|
export const descriptor = buildDevelopDescriptor();
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
|
"overrides": {
|
||||||
|
"@uncaged/json-cas": "file:../json-cas/packages/json-cas",
|
||||||
|
"@uncaged/json-cas-workflow": "file:../json-cas/packages/json-cas-workflow"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bunx tsc --build",
|
"build": "bunx tsc --build",
|
||||||
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
||||||
|
|||||||
@@ -1,5 +1,71 @@
|
|||||||
# @uncaged/cli-workflow
|
# @uncaged/cli-workflow
|
||||||
|
|
||||||
|
## 0.5.0-alpha.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- Updated dependencies [f74b482]
|
||||||
|
- Updated dependencies [f74b482]
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-execute@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-gateway@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-register@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||||
|
|
||||||
|
## 0.5.0-alpha.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-execute@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-gateway@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-register@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.3
|
||||||
|
|
||||||
|
## 0.5.0-alpha.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-execute@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-gateway@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-register@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.2
|
||||||
|
|
||||||
|
## 0.5.0-alpha.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-execute@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-gateway@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-register@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.1
|
||||||
|
|
||||||
|
## 0.5.0-alpha.0
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-execute@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-register@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-gateway@0.5.0-alpha.0
|
||||||
|
|
||||||
## 0.4.5
|
## 0.4.5
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ import { addCliArgs } from "./bundle-fixture.js";
|
|||||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {}, graph: { edges: [] } };
|
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {}, graph: { edges: [] } };
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
|
||||||
`;
|
|
||||||
|
|
||||||
function casStoredForm(raw: string): string {
|
function casStoredForm(raw: string): string {
|
||||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||||
}
|
}
|
||||||
@@ -52,12 +49,12 @@ describe("cli workflow commands", () => {
|
|||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
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, options) {
|
||||||
fs.existsSync(".");
|
fs.existsSync(".");
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, input.prompt);
|
const h = await cas.put(input.prompt);
|
||||||
yield { role: "noop", contentHash: h, meta: { done: true }, refs: [h] };
|
yield { role: "noop", contentHash: h, meta: { done: true }, refs: [h] };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
}
|
}
|
||||||
@@ -155,10 +152,9 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
|
|||||||
},
|
},
|
||||||
graph: { edges: [] },
|
graph: { edges: [] },
|
||||||
};
|
};
|
||||||
${wfPutImport}
|
|
||||||
export const run = async function* (input, options) {
|
export const run = async function* (input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, input.prompt);
|
const h = await cas.put( input.prompt);
|
||||||
yield { role: "greeter", contentHash: h, meta: { greeting: "hi" }, refs: [h] };
|
yield { role: "greeter", contentHash: h, meta: { greeting: "hi" }, refs: [h] };
|
||||||
return { returnCode: 0, summary: "ok" };
|
return { returnCode: 0, summary: "ok" };
|
||||||
};
|
};
|
||||||
@@ -197,9 +193,9 @@ export const run = async function* (input, options) {
|
|||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "x");
|
const h = await cas.put( "x");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "x" };
|
return { returnCode: 0, summary: "x" };
|
||||||
}
|
}
|
||||||
@@ -228,9 +224,9 @@ export const run = async function* (input, options) {
|
|||||||
const dtsPath = join(bundleDir, "types.d.ts");
|
const dtsPath = join(bundleDir, "types.d.ts");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "x");
|
const h = await cas.put( "x");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "x" };
|
return { returnCode: 0, summary: "x" };
|
||||||
}
|
}
|
||||||
@@ -261,9 +257,9 @@ export const run = async function* (input, options) {
|
|||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "x");
|
const h = await cas.put( "x");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "x" };
|
return { returnCode: 0, summary: "x" };
|
||||||
}
|
}
|
||||||
@@ -284,16 +280,16 @@ export const run = async function* (input, options) {
|
|||||||
const bundleDir = join(storageRoot, "src");
|
const bundleDir = join(storageRoot, "src");
|
||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "v1");
|
const h = await cas.put( "v1");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "v1" };
|
return { returnCode: 0, summary: "v1" };
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "v2");
|
const h = await cas.put( "v2");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "v2" };
|
return { returnCode: 0, summary: "v2" };
|
||||||
}
|
}
|
||||||
@@ -326,16 +322,16 @@ export const run = async function* (input, options) {
|
|||||||
const bundleDir = join(storageRoot, "src");
|
const bundleDir = join(storageRoot, "src");
|
||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "v1");
|
const h = await cas.put( "v1");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "v1" };
|
return { returnCode: 0, summary: "v1" };
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "v2");
|
const h = await cas.put( "v2");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "v2" };
|
return { returnCode: 0, summary: "v2" };
|
||||||
}
|
}
|
||||||
@@ -378,9 +374,9 @@ export const run = async function* (input, options) {
|
|||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "x");
|
const h = await cas.put( "x");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "x" };
|
return { returnCode: 0, summary: "x" };
|
||||||
}
|
}
|
||||||
@@ -391,9 +387,9 @@ export const run = async function* (input, options) {
|
|||||||
expect(add1.ok).toBe(true);
|
expect(add1.ok).toBe(true);
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "y");
|
const h = await cas.put( "y");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "y" };
|
return { returnCode: 0, summary: "y" };
|
||||||
}
|
}
|
||||||
@@ -446,9 +442,9 @@ export const run = async function* (input, options) {
|
|||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "x");
|
const h = await cas.put( "x");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "x" };
|
return { returnCode: 0, summary: "x" };
|
||||||
}
|
}
|
||||||
@@ -463,9 +459,9 @@ export const run = async function* (input, options) {
|
|||||||
const hash1 = add1.value.hash;
|
const hash1 = add1.value.hash;
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "y");
|
const h = await cas.put( "y");
|
||||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "y" };
|
return { returnCode: 0, summary: "y" };
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -2,14 +2,14 @@ import { describe, expect, test } from "bun:test";
|
|||||||
|
|
||||||
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
||||||
|
|
||||||
import { createApp } from "../src/commands/serve/app.js";
|
import { createApp } from "../src/commands/connect/app.js";
|
||||||
|
|
||||||
function casStoredForm(raw: string): string {
|
function casStoredForm(raw: string): string {
|
||||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildApp(storageRoot: string) {
|
function buildApp(storageRoot: string) {
|
||||||
const app = createApp(storageRoot);
|
const app = createApp(storageRoot, null);
|
||||||
return {
|
return {
|
||||||
fetch: (path: string, init?: RequestInit) =>
|
fetch: (path: string, init?: RequestInit) =>
|
||||||
app.fetch(new Request(`http://localhost${path}`, init)),
|
app.fetch(new Request(`http://localhost${path}`, init)),
|
||||||
@@ -115,7 +115,7 @@ describe("serve error handling", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("global error handler returns 500 with JSON", async () => {
|
test("global error handler returns 500 with JSON", async () => {
|
||||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
|
||||||
app.get("/test-error", () => {
|
app.get("/test-error", () => {
|
||||||
throw new Error("boom");
|
throw new Error("boom");
|
||||||
});
|
});
|
||||||
@@ -128,7 +128,7 @@ describe("serve error handling", () => {
|
|||||||
|
|
||||||
describe("serve security", () => {
|
describe("serve security", () => {
|
||||||
test("CORS headers present on responses", async () => {
|
test("CORS headers present on responses", async () => {
|
||||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
|
||||||
const res2 = await app.fetch(
|
const res2 = await app.fetch(
|
||||||
new Request("http://localhost/healthz", {
|
new Request("http://localhost/healthz", {
|
||||||
headers: { Origin: "http://localhost:5173" },
|
headers: { Origin: "http://localhost:5173" },
|
||||||
@@ -15,9 +15,7 @@ import { addCliArgs } from "./bundle-fixture.js";
|
|||||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||||
|
|
||||||
/** Three-role workflow that respects `input.steps` for fork/resume. */
|
/** Three-role workflow that respects `input.steps` for fork/resume. */
|
||||||
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
const threeRoleBundleSource = `export const descriptor = {
|
||||||
|
|
||||||
export const descriptor = {
|
|
||||||
description: "fork-cli",
|
description: "fork-cli",
|
||||||
roles: {
|
roles: {
|
||||||
planner: { description: "planner", schema: {} },
|
planner: { description: "planner", schema: {} },
|
||||||
@@ -30,16 +28,16 @@ export const run = async function* (input, options) {
|
|||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const has = (r) => input.steps.some((s) => s.role === r);
|
const has = (r) => input.steps.some((s) => s.role === r);
|
||||||
if (!has("planner")) {
|
if (!has("planner")) {
|
||||||
const h = await putContentMerkleNode(cas, "p1");
|
const h = await cas.put( "p1");
|
||||||
yield { role: "planner", contentHash: h, meta: { k: "planner" }, refs: [h] };
|
yield { role: "planner", contentHash: h, meta: { k: "planner" }, refs: [h] };
|
||||||
}
|
}
|
||||||
if (!has("coder")) {
|
if (!has("coder")) {
|
||||||
const h = await putContentMerkleNode(cas, "c1");
|
const h = await cas.put( "c1");
|
||||||
yield { role: "coder", contentHash: h, meta: { k: "coder" }, refs: [h] };
|
yield { role: "coder", contentHash: h, meta: { k: "coder" }, refs: [h] };
|
||||||
}
|
}
|
||||||
if (!has("reviewer")) {
|
if (!has("reviewer")) {
|
||||||
const body = "rev-" + String(input.steps.length);
|
const body = "rev-" + String(input.steps.length);
|
||||||
const h = await putContentMerkleNode(cas, body);
|
const h = await cas.put( body);
|
||||||
yield { role: "reviewer", contentHash: h, meta: { k: "reviewer" }, refs: [h] };
|
yield { role: "reviewer", contentHash: h, meta: { k: "reviewer" }, refs: [h] };
|
||||||
}
|
}
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ import { resolveThreadRecord } from "../src/thread-scan.js";
|
|||||||
import { addCliArgs } from "./bundle-fixture.js";
|
import { addCliArgs } from "./bundle-fixture.js";
|
||||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||||
|
|
||||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
|
||||||
`;
|
|
||||||
|
|
||||||
const threadFixtureDescriptor = `export const descriptor = {
|
const threadFixtureDescriptor = `export const descriptor = {
|
||||||
description: "thread-cli",
|
description: "thread-cli",
|
||||||
roles: {
|
roles: {
|
||||||
@@ -41,25 +38,23 @@ const threadFixtureDescriptor = `export const descriptor = {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const fastBundleSource = `${threadFixtureDescriptor}
|
const fastBundleSource = `${threadFixtureDescriptor}
|
||||||
${wfPutImport}
|
|
||||||
export const run = async function* (input, options) {
|
export const run = async function* (input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
let h = await putContentMerkleNode(cas, "plan");
|
let h = await cas.put( "plan");
|
||||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||||
h = await putContentMerkleNode(cas, "code");
|
h = await cas.put( "code");
|
||||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const slowPlannerBundleSource = `${threadFixtureDescriptor}
|
const slowPlannerBundleSource = `${threadFixtureDescriptor}
|
||||||
${wfPutImport}
|
|
||||||
export const run = async function* (input, options) {
|
export const run = async function* (input, options) {
|
||||||
await new Promise((r) => setTimeout(r, 400));
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
let h = await putContentMerkleNode(cas, "plan");
|
let h = await cas.put( "plan");
|
||||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||||
h = await putContentMerkleNode(cas, "code");
|
h = await cas.put( "code");
|
||||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
};
|
};
|
||||||
@@ -68,37 +63,34 @@ export const run = async function* (input, options) {
|
|||||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||||
|
|
||||||
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
|
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
|
||||||
${wfPutImport}
|
|
||||||
export const run = async function* (input, options) {
|
export const run = async function* (input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
let h = await putContentMerkleNode(cas, "plan");
|
let h = await cas.put( "plan");
|
||||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||||
await new Promise((r) => setTimeout(r, 10000));
|
await new Promise((r) => setTimeout(r, 10000));
|
||||||
h = await putContentMerkleNode(cas, "code");
|
h = await cas.put( "code");
|
||||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const pauseResumeBundleSource = `${threadFixtureDescriptor}
|
const pauseResumeBundleSource = `${threadFixtureDescriptor}
|
||||||
${wfPutImport}
|
|
||||||
export const run = async function* (_input, options) {
|
export const run = async function* (_input, options) {
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
let h = await putContentMerkleNode(cas, "f");
|
let h = await cas.put( "f");
|
||||||
yield { role: "first", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "first", contentHash: h, meta: {}, refs: [h] };
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
h = await putContentMerkleNode(cas, "s");
|
h = await cas.put( "s");
|
||||||
yield { role: "second", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "second", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
|
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
|
||||||
${wfPutImport}
|
|
||||||
export const run = async function* (_input, options) {
|
export const run = async function* (_input, options) {
|
||||||
await new Promise((r) => setTimeout(r, 900));
|
await new Promise((r) => setTimeout(r, 900));
|
||||||
const cas = options.cas;
|
const cas = options.cas;
|
||||||
const h = await putContentMerkleNode(cas, "x");
|
const h = await cas.put( "x");
|
||||||
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
|
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
};
|
};
|
||||||
@@ -180,6 +172,9 @@ describe("cli thread commands", () => {
|
|||||||
}
|
}
|
||||||
expect(threads.value.some((l) => l.includes(threadId))).toBe(true);
|
expect(threads.value.some((l) => l.includes(threadId))).toBe(true);
|
||||||
|
|
||||||
|
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
|
||||||
|
await waitUntilRunningFileAbsent(runningPath, 120);
|
||||||
|
|
||||||
const shown = await cmdThreadShow(storageRoot, threadId);
|
const shown = await cmdThreadShow(storageRoot, threadId);
|
||||||
expect(shown.ok).toBe(true);
|
expect(shown.ok).toBe(true);
|
||||||
if (!shown.ok) {
|
if (!shown.ok) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/cli-workflow",
|
"name": "@uncaged/cli-workflow",
|
||||||
"version": "0.4.5",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
"src",
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { printCliError, printCliLine } from "./cli-output.js";
|
|||||||
import { getCommandRegistry } from "./cli-registry.js";
|
import { getCommandRegistry } from "./cli-registry.js";
|
||||||
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
||||||
import { createCasDispatcher } from "./commands/cas/index.js";
|
import { createCasDispatcher } from "./commands/cas/index.js";
|
||||||
|
import { dispatchConnect } from "./commands/connect/index.js";
|
||||||
import { createInitDispatcher } from "./commands/init/index.js";
|
import { createInitDispatcher } from "./commands/init/index.js";
|
||||||
import { dispatchServe } from "./commands/serve/index.js";
|
|
||||||
import { dispatchSetup } from "./commands/setup/index.js";
|
import { dispatchSetup } from "./commands/setup/index.js";
|
||||||
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
||||||
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
||||||
@@ -71,7 +71,7 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
|
|||||||
skill: dispatchSkill,
|
skill: dispatchSkill,
|
||||||
run: dispatchRun,
|
run: dispatchRun,
|
||||||
live: dispatchLive,
|
live: dispatchLive,
|
||||||
serve: dispatchServe,
|
connect: dispatchConnect,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
|||||||
@@ -59,12 +59,12 @@ export function formatCliUsage(
|
|||||||
);
|
);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
lines.push("Server:");
|
lines.push("Gateway:");
|
||||||
lines.push(
|
lines.push(
|
||||||
...formatUsageCommandLines([
|
...formatUsageCommandLines([
|
||||||
{
|
{
|
||||||
prefix: "serve [--port N] [--host ADDR]",
|
prefix: "connect [--name NAME] [--gateway URL]",
|
||||||
description: "Start HTTP API server (default: 127.0.0.1:7860)",
|
description: "Connect to workflow gateway via WebSocket",
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|||||||
+5
-5
@@ -8,7 +8,7 @@ import { createWorkflowRoutes } from "./routes-workflow.js";
|
|||||||
|
|
||||||
const MAX_BODY_SIZE = 1_048_576; // 1 MB
|
const MAX_BODY_SIZE = 1_048_576; // 1 MB
|
||||||
|
|
||||||
export function createApp(storageRoot: string, agentToken: string | null): Hono {
|
export function createApp(storageRoot: string, clientToken: string | null): Hono {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
app.onError((_err, c) => {
|
app.onError((_err, c) => {
|
||||||
@@ -37,11 +37,11 @@ export function createApp(storageRoot: string, agentToken: string | null): Hono
|
|||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Agent token auth (skip healthz) ───────────────────────────────
|
// ── Client token auth (skip healthz) ───────────────────────────────
|
||||||
if (agentToken !== null) {
|
if (clientToken !== null) {
|
||||||
app.use("/api/*", async (c, next) => {
|
app.use("/api/*", async (c, next) => {
|
||||||
const token = c.req.header("X-Agent-Token");
|
const token = c.req.header("X-Client-Token");
|
||||||
if (token !== agentToken) {
|
if (token !== clientToken) {
|
||||||
return c.json({ error: "unauthorized" }, 401);
|
return c.json({ error: "unauthorized" }, 401);
|
||||||
}
|
}
|
||||||
await next();
|
await next();
|
||||||
+18
-61
@@ -1,63 +1,30 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { hostname as osHostname } from "node:os";
|
import { hostname as osHostname } from "node:os";
|
||||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||||
import { createLogger } from "@uncaged/workflow-util";
|
import { createLogger } from "@uncaged/workflow-util";
|
||||||
import { serve } from "bun";
|
|
||||||
|
|
||||||
import { printCliLine } from "../../cli-output.js";
|
import { printCliLine } from "../../cli-output.js";
|
||||||
import { createApp } from "./app.js";
|
import { createApp } from "./app.js";
|
||||||
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./gateway.js";
|
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./gateway.js";
|
||||||
import type { ServeOptions } from "./types.js";
|
import type { ConnectOptions } from "./types.js";
|
||||||
import { startGatewayWsClient } from "./ws-client.js";
|
import { startGatewayWsClient } from "./ws-client.js";
|
||||||
|
|
||||||
const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev";
|
const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev";
|
||||||
const HEARTBEAT_INTERVAL_MS = 60_000;
|
const HEARTBEAT_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
export function startServer(
|
|
||||||
storageRoot: string,
|
|
||||||
options: ServeOptions,
|
|
||||||
agentToken: string | null,
|
|
||||||
): void {
|
|
||||||
const app = createApp(storageRoot, agentToken);
|
|
||||||
|
|
||||||
const server = serve({
|
|
||||||
fetch: app.fetch,
|
|
||||||
port: options.port,
|
|
||||||
hostname: options.hostname,
|
|
||||||
});
|
|
||||||
|
|
||||||
printCliLine(`uncaged-workflow API server listening on http://${server.hostname}:${server.port}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePortValue(value: string | undefined): Result<number, string> {
|
|
||||||
if (value === undefined) {
|
|
||||||
return err("--port requires a value");
|
|
||||||
}
|
|
||||||
const parsed = Number.parseInt(value, 10);
|
|
||||||
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 65535) {
|
|
||||||
return err(`invalid port: ${value}`);
|
|
||||||
}
|
|
||||||
return ok(parsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireNextArg(argv: string[], i: number, flag: string): Result<string, string> {
|
function requireNextArg(argv: string[], i: number, flag: string): Result<string, string> {
|
||||||
const next = argv[i + 1];
|
const next = argv[i + 1];
|
||||||
if (next === undefined) {
|
if (next === undefined) {
|
||||||
return err(`${flag} requires a value`);
|
return { ok: false, error: `${flag} requires a value` };
|
||||||
}
|
}
|
||||||
return ok(next);
|
return ok(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
function parseConnectArgv(argv: string[]): Result<ConnectOptions, string> {
|
||||||
let port = 7860;
|
|
||||||
let hostname = "127.0.0.1";
|
|
||||||
let name = osHostname().split(".")[0].toLowerCase();
|
let name = osHostname().split(".")[0].toLowerCase();
|
||||||
let gatewayUrl = DEFAULT_GATEWAY_URL;
|
let gatewayUrl = DEFAULT_GATEWAY_URL;
|
||||||
const gatewaySecret = process.env.WORKFLOW_GATEWAY_SECRET ?? "";
|
const gatewaySecret = process.env.WORKFLOW_DASHBOARD_SECRET ?? "";
|
||||||
const stringFlags: Record<string, (v: string) => void> = {
|
const stringFlags: Record<string, (v: string) => void> = {
|
||||||
"--host": (v) => {
|
|
||||||
hostname = v;
|
|
||||||
},
|
|
||||||
"--name": (v) => {
|
"--name": (v) => {
|
||||||
name = v;
|
name = v;
|
||||||
},
|
},
|
||||||
@@ -68,12 +35,7 @@ function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
|||||||
|
|
||||||
for (let i = 0; i < argv.length; i++) {
|
for (let i = 0; i < argv.length; i++) {
|
||||||
const arg = argv[i];
|
const arg = argv[i];
|
||||||
if (arg === "--port" || arg === "-p") {
|
if (arg in stringFlags) {
|
||||||
const portResult = parsePortValue(argv[i + 1]);
|
|
||||||
if (!portResult.ok) return portResult;
|
|
||||||
port = portResult.value;
|
|
||||||
i++;
|
|
||||||
} else if (arg in stringFlags) {
|
|
||||||
const r = requireNextArg(argv, i, arg);
|
const r = requireNextArg(argv, i, arg);
|
||||||
if (!r.ok) return r;
|
if (!r.ok) return r;
|
||||||
stringFlags[arg](r.value);
|
stringFlags[arg](r.value);
|
||||||
@@ -81,11 +43,11 @@ function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ok({ port, hostname, name, gatewayUrl, gatewaySecret });
|
return ok({ name, gatewayUrl, gatewaySecret });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
|
export async function dispatchConnect(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
const parsed = parseServeArgv(argv);
|
const parsed = parseConnectArgv(argv);
|
||||||
if (!parsed.ok) {
|
if (!parsed.ok) {
|
||||||
printCliLine(`error: ${parsed.error}`);
|
printCliLine(`error: ${parsed.error}`);
|
||||||
return 1;
|
return 1;
|
||||||
@@ -94,36 +56,31 @@ export async function dispatchServe(storageRoot: string, argv: string[]): Promis
|
|||||||
const options = parsed.value;
|
const options = parsed.value;
|
||||||
|
|
||||||
if (options.gatewaySecret === "") {
|
if (options.gatewaySecret === "") {
|
||||||
// No gateway — local-only mode
|
printCliLine("error: WORKFLOW_DASHBOARD_SECRET is required");
|
||||||
startServer(storageRoot, options, null);
|
return 1;
|
||||||
printCliLine("no WORKFLOW_GATEWAY_SECRET — running in local-only mode");
|
|
||||||
await new Promise(() => {});
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentToken = randomUUID();
|
const clientToken = randomUUID();
|
||||||
startServer(storageRoot, options, agentToken);
|
const app = createApp(storageRoot, clientToken);
|
||||||
|
|
||||||
// Start WebSocket reverse connection to gateway
|
|
||||||
const log = createLogger({ sink: { kind: "stderr" } });
|
const log = createLogger({ sink: { kind: "stderr" } });
|
||||||
const stopWsClient = startGatewayWsClient({
|
const stopWsClient = startGatewayWsClient({
|
||||||
gatewayUrl: options.gatewayUrl,
|
gatewayUrl: options.gatewayUrl,
|
||||||
name: options.name,
|
name: options.name,
|
||||||
secret: options.gatewaySecret,
|
secret: options.gatewaySecret,
|
||||||
localPort: options.port,
|
appFetch: app.fetch,
|
||||||
log,
|
log,
|
||||||
});
|
});
|
||||||
|
|
||||||
printCliLine("connected to gateway via WebSocket");
|
printCliLine("connected to gateway via WebSocket");
|
||||||
|
|
||||||
// Register with gateway for discovery
|
// Register with gateway for discovery
|
||||||
const localUrl = `http://127.0.0.1:${options.port}`;
|
|
||||||
const registered = await registerWithGateway(
|
const registered = await registerWithGateway(
|
||||||
options.gatewayUrl,
|
options.gatewayUrl,
|
||||||
options.name,
|
options.name,
|
||||||
localUrl,
|
`ws://${options.name}`,
|
||||||
options.gatewaySecret,
|
options.gatewaySecret,
|
||||||
agentToken,
|
clientToken,
|
||||||
);
|
);
|
||||||
if (registered) {
|
if (registered) {
|
||||||
printCliLine(`registered with gateway as "${options.name}"`);
|
printCliLine(`registered with gateway as "${options.name}"`);
|
||||||
@@ -132,9 +89,9 @@ export async function dispatchServe(storageRoot: string, argv: string[]): Promis
|
|||||||
const heartbeatTimer = startHeartbeat(
|
const heartbeatTimer = startHeartbeat(
|
||||||
options.gatewayUrl,
|
options.gatewayUrl,
|
||||||
options.name,
|
options.name,
|
||||||
localUrl,
|
`ws://${options.name}`,
|
||||||
options.gatewaySecret,
|
options.gatewaySecret,
|
||||||
agentToken,
|
clientToken,
|
||||||
HEARTBEAT_INTERVAL_MS,
|
HEARTBEAT_INTERVAL_MS,
|
||||||
);
|
);
|
||||||
|
|
||||||
+4
-4
@@ -5,13 +5,13 @@ export async function registerWithGateway(
|
|||||||
name: string,
|
name: string,
|
||||||
localUrl: string,
|
localUrl: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
agentToken: string,
|
clientToken: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${gatewayUrl}/api/gateway/register`, {
|
const resp = await fetch(`${gatewayUrl}/api/gateway/register`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name, url: localUrl, secret, agentToken }),
|
body: JSON.stringify({ name, url: localUrl, secret, clientToken }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const body = await resp.text();
|
const body = await resp.text();
|
||||||
@@ -45,10 +45,10 @@ export function startHeartbeat(
|
|||||||
name: string,
|
name: string,
|
||||||
localUrl: string,
|
localUrl: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
agentToken: string,
|
clientToken: string,
|
||||||
intervalMs: number,
|
intervalMs: number,
|
||||||
): ReturnType<typeof setInterval> {
|
): ReturnType<typeof setInterval> {
|
||||||
return setInterval(() => {
|
return setInterval(() => {
|
||||||
registerWithGateway(gatewayUrl, name, localUrl, secret, agentToken).catch(() => {});
|
registerWithGateway(gatewayUrl, name, localUrl, secret, clientToken).catch(() => {});
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { dispatchConnect } from "./connect.js";
|
||||||
|
export type { ConnectOptions } from "./types.js";
|
||||||
+1
-3
@@ -1,6 +1,4 @@
|
|||||||
export type ServeOptions = {
|
export type ConnectOptions = {
|
||||||
port: number;
|
|
||||||
hostname: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
gatewaySecret: string;
|
gatewaySecret: string;
|
||||||
+11
-12
@@ -5,7 +5,7 @@ export type GatewayWsClientParams = {
|
|||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
name: string;
|
name: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
localPort: number;
|
appFetch: (request: Request) => Response | Promise<Response>;
|
||||||
log: LogFn;
|
log: LogFn;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,20 +44,19 @@ async function handleGatewayMessage(
|
|||||||
params.log("ZM8K2PQ1", "gateway WebSocket dropped non-request message");
|
params.log("ZM8K2PQ1", "gateway WebSocket dropped non-request message");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const localUrl = `http://127.0.0.1:${String(params.localPort)}${req.path}`;
|
const localUrl = `http://localhost${req.path}`;
|
||||||
const initHeaders = new Headers();
|
const headers = new Headers(req.headers);
|
||||||
for (const [k, v] of Object.entries(req.headers)) {
|
|
||||||
initHeaders.set(k, v);
|
|
||||||
}
|
|
||||||
let resp: Response;
|
let resp: Response;
|
||||||
try {
|
try {
|
||||||
resp = await fetch(localUrl, {
|
resp = await params.appFetch(
|
||||||
method: req.method,
|
new Request(localUrl, {
|
||||||
headers: initHeaders,
|
method: req.method,
|
||||||
body: req.body === null ? undefined : req.body,
|
headers,
|
||||||
});
|
body: req.body === null ? undefined : req.body,
|
||||||
|
}),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
params.log("R4N7BQ3C", `local proxy fetch failed: ${String(e)}`);
|
params.log("R4N7BQ3C", `app.fetch failed: ${String(e)}`);
|
||||||
const errBody: WsResponse = {
|
const errBody: WsResponse = {
|
||||||
id: req.id,
|
id: req.id,
|
||||||
status: 502,
|
status: 502,
|
||||||
@@ -51,7 +51,6 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
|||||||
description: "Says hello — replace with your first role.",
|
description: "Says hello — replace with your first role.",
|
||||||
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
|
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
|
||||||
schema: greeterMetaSchema,
|
schema: greeterMetaSchema,
|
||||||
extractRefs: null,
|
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,18 +196,13 @@ uncaged-workflow init workspace ${workspaceName}
|
|||||||
|
|
||||||
function bundleTs(): string {
|
function bundleTs(): string {
|
||||||
return [
|
return [
|
||||||
'import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";',
|
'import { mkdir, readdir, writeFile } from "node:fs/promises";',
|
||||||
'import { join } from "node:path";',
|
'import { join } from "node:path";',
|
||||||
"",
|
"",
|
||||||
'const rootDir = join(import.meta.dir, "..");',
|
'const rootDir = join(import.meta.dir, "..");',
|
||||||
'const workflowsDir = join(rootDir, "workflows");',
|
'const workflowsDir = join(rootDir, "workflows");',
|
||||||
'const distDir = join(rootDir, "dist");',
|
'const distDir = join(rootDir, "dist");',
|
||||||
"",
|
"",
|
||||||
"type JsonDeps = {",
|
|
||||||
" dependencies: Record<string, string> | null;",
|
|
||||||
" devDependencies: Record<string, string> | null;",
|
|
||||||
"};",
|
|
||||||
"",
|
|
||||||
"function isEntryFile(name: string): boolean {",
|
"function isEntryFile(name: string): boolean {",
|
||||||
' return name.endsWith("-entry.ts");',
|
' return name.endsWith("-entry.ts");',
|
||||||
"}",
|
"}",
|
||||||
@@ -216,36 +211,6 @@ function bundleTs(): string {
|
|||||||
' return name.slice(0, -".ts".length);',
|
' return name.slice(0, -".ts".length);',
|
||||||
"}",
|
"}",
|
||||||
"",
|
"",
|
||||||
"async function uncagedWorkflowExternals(): Promise<string[]> {",
|
|
||||||
" const names = new Set<string>();",
|
|
||||||
' const paths = [join(rootDir, "package.json"), join(workflowsDir, "package.json")];',
|
|
||||||
" for (const pkgPath of paths) {",
|
|
||||||
" let raw: string;",
|
|
||||||
" try {",
|
|
||||||
' raw = await readFile(pkgPath, "utf8");',
|
|
||||||
" } catch {",
|
|
||||||
" continue;",
|
|
||||||
" }",
|
|
||||||
" const parsed = JSON.parse(raw) as JsonDeps;",
|
|
||||||
" const blocks = [parsed.dependencies, parsed.devDependencies];",
|
|
||||||
" for (const block of blocks) {",
|
|
||||||
" if (block == null) {",
|
|
||||||
" continue;",
|
|
||||||
" }",
|
|
||||||
" for (const key of Object.keys(block)) {",
|
|
||||||
' if (key.startsWith("@uncaged/workflow")) {',
|
|
||||||
" names.add(key);",
|
|
||||||
" }",
|
|
||||||
" }",
|
|
||||||
" }",
|
|
||||||
" }",
|
|
||||||
" if (names.size === 0) {",
|
|
||||||
' names.add("@uncaged/workflow-runtime");',
|
|
||||||
' names.add("@uncaged/workflow-protocol");',
|
|
||||||
" }",
|
|
||||||
" return [...names];",
|
|
||||||
"}",
|
|
||||||
"",
|
|
||||||
"async function main(): Promise<void> {",
|
"async function main(): Promise<void> {",
|
||||||
" await mkdir(distDir, { recursive: true });",
|
" await mkdir(distDir, { recursive: true });",
|
||||||
" let files: string[];",
|
" let files: string[];",
|
||||||
@@ -261,7 +226,6 @@ function bundleTs(): string {
|
|||||||
' console.warn("bundle: no *-entry.ts files under workflows/");',
|
' console.warn("bundle: no *-entry.ts files under workflows/");',
|
||||||
" return;",
|
" return;",
|
||||||
" }",
|
" }",
|
||||||
" const external = await uncagedWorkflowExternals();",
|
|
||||||
" for (const file of entries) {",
|
" for (const file of entries) {",
|
||||||
" const stem = entryStem(file);",
|
" const stem = entryStem(file);",
|
||||||
" const entryPath = join(workflowsDir, file);",
|
" const entryPath = join(workflowsDir, file);",
|
||||||
@@ -272,7 +236,6 @@ function bundleTs(): string {
|
|||||||
' target: "node",',
|
' target: "node",',
|
||||||
" splitting: false,",
|
" splitting: false,",
|
||||||
' naming: { entry: "[name].esm.js" },',
|
' naming: { entry: "[name].esm.js" },',
|
||||||
" external,",
|
|
||||||
" });",
|
" });",
|
||||||
" if (!result.success) {",
|
" if (!result.success) {",
|
||||||
" for (const log of result.logs) {",
|
" for (const log of result.logs) {",
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export { createApp } from "./app.js";
|
|
||||||
export { dispatchServe, startServer } from "./serve.js";
|
|
||||||
export type { ServeOptions } from "./types.js";
|
|
||||||
@@ -18,13 +18,13 @@ export async function cmdThreadRemove(
|
|||||||
return err(`thread not found: ${threadId}`);
|
return err(`thread not found: ${threadId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolved.source === "active") {
|
// Always clear both stores: between resolve and delete the worker may finish and
|
||||||
await removeThreadEntry(resolved.bundleDir, threadId);
|
// move the thread from threads.json into history; branching only on resolved.source
|
||||||
} else {
|
// would skip history removal and leave a dangling row.
|
||||||
const hist = await removeThreadHistoryEntries(resolved.bundleDir, threadId);
|
await removeThreadEntry(resolved.bundleDir, threadId);
|
||||||
if (!hist.ok) {
|
const hist = await removeThreadHistoryEntries(resolved.bundleDir, threadId);
|
||||||
return hist;
|
if (!hist.ok) {
|
||||||
}
|
return hist;
|
||||||
}
|
}
|
||||||
|
|
||||||
const infoPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.info.jsonl`);
|
const infoPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.info.jsonl`);
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export async function cmdAdd(
|
|||||||
return validated;
|
return validated;
|
||||||
}
|
}
|
||||||
|
|
||||||
const extracted = await extractBundleExports(resolvedPath, { storageRoot });
|
const extracted = await extractBundleExports(resolvedPath);
|
||||||
if (!extracted.ok) {
|
if (!extracted.ok) {
|
||||||
return extracted;
|
return extracted;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,11 +86,11 @@ ${commandSections.join("\n\n")}
|
|||||||
| \`run\` | \`thread run\` | Shortcut to start a thread |
|
| \`run\` | \`thread run\` | Shortcut to start a thread |
|
||||||
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
|
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
|
||||||
|
|
||||||
### serve
|
### connect
|
||||||
|
|
||||||
| Command | Args | Description |
|
| Command | Args | Description |
|
||||||
|---------|------|-------------|
|
|---------|------|-------------|
|
||||||
| \`serve\` | \`[--port N] [--host ADDR] [--name NAME]\` | Start HTTP API server with WebSocket gateway connection. \`--name\` registers with the gateway. |
|
| \`connect\` | \`[--name NAME] [--gateway URL]\` | Connect to workflow gateway via WebSocket. \`--name\` registers with the gateway. |
|
||||||
|
|
||||||
## Typical Workflow
|
## Typical Workflow
|
||||||
|
|
||||||
@@ -249,8 +249,7 @@ Each role has:
|
|||||||
|-------|------|---------|
|
|-------|------|---------|
|
||||||
| \`description\` | string | What the role does |
|
| \`description\` | string | What the role does |
|
||||||
| \`systemPrompt\` | string | System prompt for the agent |
|
| \`systemPrompt\` | string | System prompt for the agent |
|
||||||
| \`schema\` | ZodSchema | Validates the extracted meta |
|
| \`schema\` | ZodSchema | Validates meta; annotate CAS hash strings with \`.meta({ casRef: true })\` for DAG linking |
|
||||||
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
|
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|
||||||
@@ -301,13 +300,36 @@ function createLazyAdapter(): AdapterFn {
|
|||||||
}
|
}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
|
### Agent CLI paths: use env() with absolute path defaults
|
||||||
|
|
||||||
|
Every env var in a bundle must have a sensible default — bundles must run without any env vars set. Use \`env(name, fallback)\` from \`@uncaged/workflow-util\`.
|
||||||
|
|
||||||
|
Discover the correct CLI path yourself (e.g. \`which cursor-agent\`, \`which hermes\`) and hardcode it as the fallback:
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
import { env } from "@uncaged/workflow-util";
|
||||||
|
|
||||||
|
// ❌ WRONG — requireEnv and optionalEnv no longer exist
|
||||||
|
const adapter = createCursorAgent({
|
||||||
|
command: requireEnv("WORKFLOW_CURSOR_COMMAND", "set it"),
|
||||||
|
...
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ CORRECT — env var is an override, fallback is the discovered absolute path
|
||||||
|
const adapter = createCursorAgent({
|
||||||
|
command: env("WORKFLOW_CURSOR_COMMAND", "/home/you/.local/bin/cursor-agent"),
|
||||||
|
model: env("WORKFLOW_CURSOR_MODEL", "auto"),
|
||||||
|
timeout: Number(env("WORKFLOW_CURSOR_TIMEOUT", "300000")),
|
||||||
|
...
|
||||||
|
});
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
### Bundle import restrictions
|
### Bundle import restrictions
|
||||||
|
|
||||||
The bundle validator only allows these import specifiers:
|
The bundle validator only allows these import specifiers:
|
||||||
- Node built-ins (\`node:fs\`, \`node:path\`, etc.)
|
- Node built-ins (\`node:fs\`, \`node:path\`, etc.)
|
||||||
- \`@uncaged/workflow-*\` packages
|
|
||||||
|
|
||||||
Third-party packages (**including zod**) must be bundled into the \`.esm.js\` file, not left as external imports. When using \`bun build\`, only mark \`@uncaged/*\` as external.
|
All other dependencies — including \`@uncaged/workflow-*\` packages, zod, and any third-party code — must be bundled into the \`.esm.js\` file. Bundles are fully self-contained: same Node/Bun version = same behavior.
|
||||||
|
|
||||||
### No default exports
|
### No default exports
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,62 @@
|
|||||||
# @uncaged/workflow-agent-cursor
|
# @uncaged/workflow-agent-cursor
|
||||||
|
|
||||||
|
## 0.5.0-alpha.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- Updated dependencies [f74b482]
|
||||||
|
- Updated dependencies [f74b482]
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.4
|
||||||
|
|
||||||
|
## 0.5.0-alpha.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.3
|
||||||
|
|
||||||
|
## 0.5.0-alpha.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.2
|
||||||
|
|
||||||
|
## 0.5.0-alpha.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.1
|
||||||
|
|
||||||
|
## 0.5.0-alpha.0
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.0
|
||||||
|
|
||||||
## 0.4.5
|
## 0.4.5
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||||
|
|
||||||
|
const baseConfig = {
|
||||||
|
command: "/usr/local/bin/cursor-agent",
|
||||||
|
model: null as string | null,
|
||||||
|
timeout: 0,
|
||||||
|
workspace: null as string | null,
|
||||||
|
};
|
||||||
|
|
||||||
describe("validateCursorAgentConfig", () => {
|
describe("validateCursorAgentConfig", () => {
|
||||||
test("accepts valid config", () => {
|
test("accepts valid config", () => {
|
||||||
const r = validateCursorAgentConfig({
|
const r = validateCursorAgentConfig({
|
||||||
command: "/usr/local/bin/cursor-agent",
|
...baseConfig,
|
||||||
model: null,
|
|
||||||
timeout: 0,
|
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects non-absolute command", () => {
|
test("rejects non-absolute command", () => {
|
||||||
const r = validateCursorAgentConfig({
|
const r = validateCursorAgentConfig({
|
||||||
|
...baseConfig,
|
||||||
command: "cursor-agent",
|
command: "cursor-agent",
|
||||||
model: null,
|
|
||||||
timeout: 0,
|
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
@@ -25,28 +29,35 @@ describe("validateCursorAgentConfig", () => {
|
|||||||
|
|
||||||
test("rejects negative timeout", () => {
|
test("rejects negative timeout", () => {
|
||||||
const r = validateCursorAgentConfig({
|
const r = validateCursorAgentConfig({
|
||||||
command: "/usr/local/bin/cursor-agent",
|
...baseConfig,
|
||||||
model: null,
|
|
||||||
timeout: -1,
|
timeout: -1,
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rejects non-absolute workspace when set", () => {
|
||||||
|
const r = validateCursorAgentConfig({
|
||||||
|
...baseConfig,
|
||||||
|
workspace: "relative/path",
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
expect(r.error).toContain("workspace");
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createCursorAgent", () => {
|
describe("createCursorAgent", () => {
|
||||||
test("returns an AdapterFn", () => {
|
test("returns an AdapterFn", () => {
|
||||||
const agent = createCursorAgent({
|
const agent = createCursorAgent({
|
||||||
command: "/usr/local/bin/cursor-agent",
|
...baseConfig,
|
||||||
model: null,
|
|
||||||
timeout: 0,
|
|
||||||
});
|
});
|
||||||
expect(typeof agent).toBe("function");
|
expect(typeof agent).toBe("function");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("defers validation to call time (invalid config does not throw at construction)", () => {
|
test("defers validation to call time (invalid config does not throw at construction)", () => {
|
||||||
const agent = createCursorAgent({
|
const agent = createCursorAgent({
|
||||||
command: "/usr/local/bin/cursor-agent",
|
...baseConfig,
|
||||||
model: null,
|
|
||||||
timeout: -1,
|
timeout: -1,
|
||||||
});
|
});
|
||||||
expect(typeof agent).toBe("function");
|
expect(typeof agent).toBe("function");
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { packageDescriptor } from "../src/index.js";
|
||||||
|
|
||||||
|
describe("packageDescriptor", () => {
|
||||||
|
test("has the correct package name", () => {
|
||||||
|
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-cursor");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("has a non-empty version string", () => {
|
||||||
|
expect(typeof packageDescriptor.version).toBe("string");
|
||||||
|
expect(packageDescriptor.version.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("capabilities is a non-empty array of strings", () => {
|
||||||
|
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
|
||||||
|
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
|
||||||
|
for (const cap of packageDescriptor.capabilities) {
|
||||||
|
expect(typeof cap).toBe("string");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configSchema is an object with type 'object'", () => {
|
||||||
|
expect(typeof packageDescriptor.configSchema).toBe("object");
|
||||||
|
expect(packageDescriptor.configSchema.type).toBe("object");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configSchema requires 'command' and 'timeout'", () => {
|
||||||
|
const required = packageDescriptor.configSchema.required as string[];
|
||||||
|
expect(required).toContain("command");
|
||||||
|
expect(required).toContain("timeout");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configSchema properties include command, model, timeout, workspace", () => {
|
||||||
|
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
|
||||||
|
expect(props).toHaveProperty("command");
|
||||||
|
expect(props).toHaveProperty("model");
|
||||||
|
expect(props).toHaveProperty("timeout");
|
||||||
|
expect(props).toHaveProperty("workspace");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-agent-cursor",
|
"name": "@uncaged/workflow-agent-cursor",
|
||||||
"version": "0.4.5",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
"src",
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { WorkflowRuntime } from "@uncaged/workflow-runtime";
|
import type { AdapterFn, AgentFn, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||||
import { createLogger } from "@uncaged/workflow-util";
|
import { createLogger, type LogFn } from "@uncaged/workflow-util";
|
||||||
import {
|
import {
|
||||||
buildThreadInput,
|
buildThreadInput,
|
||||||
createTextAdapter,
|
createAgentAdapter,
|
||||||
type SpawnCliError,
|
type SpawnCliError,
|
||||||
spawnCli,
|
spawnCli,
|
||||||
} from "@uncaged/workflow-util-agent";
|
} from "@uncaged/workflow-util-agent";
|
||||||
@@ -11,6 +11,7 @@ import { extractWorkspacePath } from "./extract-workspace.js";
|
|||||||
import type { CursorAgentConfig } from "./types.js";
|
import type { CursorAgentConfig } from "./types.js";
|
||||||
import { validateCursorAgentConfig } from "./validate-config.js";
|
import { validateCursorAgentConfig } from "./validate-config.js";
|
||||||
|
|
||||||
|
export { packageDescriptor } from "./package-descriptor.js";
|
||||||
export type { CursorAgentConfig } from "./types.js";
|
export type { CursorAgentConfig } from "./types.js";
|
||||||
export { validateCursorAgentConfig } from "./validate-config.js";
|
export { validateCursorAgentConfig } from "./validate-config.js";
|
||||||
|
|
||||||
@@ -33,25 +34,15 @@ function resolveCursorModel(model: string | null): string {
|
|||||||
return model === null ? "auto" : model;
|
return model === null ? "auto" : model;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Runs `cursor-agent` with workspace extracted from thread context via runtime.extract. */
|
type CursorAgentOpt = { prompt: string; workspace: string };
|
||||||
export function createCursorAgent(config: CursorAgentConfig) {
|
|
||||||
const modelFlag = resolveCursorModel(config.model);
|
|
||||||
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
|
||||||
const logger = createLogger({ sink: { kind: "stderr" } });
|
|
||||||
|
|
||||||
return createTextAdapter(async (ctx, prompt, runtime: WorkflowRuntime) => {
|
|
||||||
const validated = validateCursorAgentConfig(config);
|
|
||||||
if (!validated.ok) {
|
|
||||||
throw new Error(validated.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspace = await extractWorkspacePath(ctx, runtime, logger);
|
|
||||||
if (workspace === null) {
|
|
||||||
throw new Error(
|
|
||||||
"cursor-agent: failed to extract workspace path from context. Ensure the task prompt or previous steps include a project path.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function createCursorAgentFn(
|
||||||
|
config: CursorAgentConfig,
|
||||||
|
modelFlag: string,
|
||||||
|
timeoutMs: number | null,
|
||||||
|
logger: LogFn,
|
||||||
|
): AgentFn<CursorAgentOpt> {
|
||||||
|
return async (ctx, { prompt, workspace }) => {
|
||||||
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
|
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
|
||||||
const threadInput = await buildThreadInput(ctx);
|
const threadInput = await buildThreadInput(ctx);
|
||||||
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
||||||
@@ -75,5 +66,33 @@ export function createCursorAgent(config: CursorAgentConfig) {
|
|||||||
throwCursorSpawnError(run.error);
|
throwCursorSpawnError(run.error);
|
||||||
}
|
}
|
||||||
return run.value;
|
return run.value;
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs `cursor-agent` with workspace from config or extracted from thread context via runtime.extract. */
|
||||||
|
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
|
||||||
|
const modelFlag = resolveCursorModel(config.model);
|
||||||
|
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
||||||
|
const logger = createLogger({ sink: { kind: "stderr" } });
|
||||||
|
|
||||||
|
return createAgentAdapter(
|
||||||
|
createCursorAgentFn(config, modelFlag, timeoutMs, logger),
|
||||||
|
async (ctx, prompt, runtime: WorkflowRuntime) => {
|
||||||
|
const validated = validateCursorAgentConfig(config);
|
||||||
|
if (!validated.ok) {
|
||||||
|
throw new Error(validated.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace =
|
||||||
|
config.workspace !== null
|
||||||
|
? config.workspace
|
||||||
|
: await extractWorkspacePath(ctx, runtime, logger);
|
||||||
|
if (workspace === null) {
|
||||||
|
throw new Error(
|
||||||
|
"cursor-agent: failed to extract workspace path from context. Ensure the task prompt or previous steps include a project path.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { prompt, workspace };
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { PackageDescriptor } from "@uncaged/workflow-protocol";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static metadata for @uncaged/workflow-agent-cursor.
|
||||||
|
* Config maps to {@link CursorAgentConfig}.
|
||||||
|
*/
|
||||||
|
export const packageDescriptor: PackageDescriptor = {
|
||||||
|
name: "@uncaged/workflow-agent-cursor",
|
||||||
|
version: "0.5.0-alpha.4",
|
||||||
|
capabilities: ["cursor-cli", "workspace-agent"],
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["command", "timeout"],
|
||||||
|
properties: {
|
||||||
|
command: {
|
||||||
|
type: "string",
|
||||||
|
description: "Absolute path to the cursor-agent CLI binary.",
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
anyOf: [{ type: "string" }, { type: "null" }],
|
||||||
|
description: "Model identifier passed to cursor-agent --model; null means auto.",
|
||||||
|
},
|
||||||
|
timeout: {
|
||||||
|
type: "number",
|
||||||
|
description: "Timeout in milliseconds; 0 means no limit.",
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
anyOf: [{ type: "string" }, { type: "null" }],
|
||||||
|
description: "Override workspace path; null resolves from thread context.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -3,4 +3,9 @@ export type CursorAgentConfig = {
|
|||||||
command: string;
|
command: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
timeout: number;
|
timeout: number;
|
||||||
|
/**
|
||||||
|
* When non-null, use this workspace directory for `cursor-agent` instead of resolving it
|
||||||
|
* from the thread via runtime extraction.
|
||||||
|
*/
|
||||||
|
workspace: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,5 +11,8 @@ export function validateCursorAgentConfig(config: CursorAgentConfig): Result<voi
|
|||||||
if (config.timeout < 0) {
|
if (config.timeout < 0) {
|
||||||
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
|
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
|
||||||
}
|
}
|
||||||
|
if (config.workspace !== null && !isAbsolute(config.workspace)) {
|
||||||
|
return err("workspace must be an absolute filesystem path when set");
|
||||||
|
}
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,9 @@
|
|||||||
"composite": true
|
"composite": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
|
"references": [
|
||||||
|
{ "path": "../workflow-cas" },
|
||||||
|
{ "path": "../workflow-runtime" },
|
||||||
|
{ "path": "../workflow-util-agent" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,41 @@
|
|||||||
# @uncaged/workflow-agent-hermes
|
# @uncaged/workflow-agent-hermes
|
||||||
|
|
||||||
|
## 0.5.0-alpha.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.4
|
||||||
|
|
||||||
|
## 0.5.0-alpha.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.3
|
||||||
|
|
||||||
|
## 0.5.0-alpha.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.2
|
||||||
|
|
||||||
|
## 0.5.0-alpha.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||||
|
|
||||||
|
## 0.5.0-alpha.0
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.0
|
||||||
|
|
||||||
## 0.4.5
|
## 0.4.5
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { packageDescriptor } from "../src/index.js";
|
||||||
|
|
||||||
|
describe("packageDescriptor", () => {
|
||||||
|
test("has the correct package name", () => {
|
||||||
|
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-hermes");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("has a non-empty version string", () => {
|
||||||
|
expect(typeof packageDescriptor.version).toBe("string");
|
||||||
|
expect(packageDescriptor.version.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("capabilities is a non-empty array of strings", () => {
|
||||||
|
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
|
||||||
|
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
|
||||||
|
for (const cap of packageDescriptor.capabilities) {
|
||||||
|
expect(typeof cap).toBe("string");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configSchema is an object with type 'object'", () => {
|
||||||
|
expect(typeof packageDescriptor.configSchema).toBe("object");
|
||||||
|
expect(packageDescriptor.configSchema.type).toBe("object");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configSchema requires 'command'", () => {
|
||||||
|
const required = packageDescriptor.configSchema.required as string[];
|
||||||
|
expect(required).toContain("command");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configSchema properties include command, model, timeout", () => {
|
||||||
|
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
|
||||||
|
expect(props).toHaveProperty("command");
|
||||||
|
expect(props).toHaveProperty("model");
|
||||||
|
expect(props).toHaveProperty("timeout");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-agent-hermes",
|
"name": "@uncaged/workflow-agent-hermes",
|
||||||
"version": "0.4.5",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
"src",
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { AdapterFn } from "@uncaged/workflow-runtime";
|
import type { AdapterFn, AgentFn } from "@uncaged/workflow-runtime";
|
||||||
import {
|
import {
|
||||||
buildThreadInput,
|
buildThreadInput,
|
||||||
createTextAdapter,
|
createAgentAdapter,
|
||||||
type SpawnCliError,
|
type SpawnCliError,
|
||||||
spawnCli,
|
spawnCli,
|
||||||
} from "@uncaged/workflow-util-agent";
|
} from "@uncaged/workflow-util-agent";
|
||||||
@@ -11,6 +11,9 @@ import { validateHermesAgentConfig } from "./validate-config.js";
|
|||||||
|
|
||||||
const HERMES_DEFAULT_MAX_TURNS = 90;
|
const HERMES_DEFAULT_MAX_TURNS = 90;
|
||||||
|
|
||||||
|
type HermesAgentOpt = { prompt: string };
|
||||||
|
|
||||||
|
export { packageDescriptor } from "./package-descriptor.js";
|
||||||
export type { HermesAgentConfig } from "./types.js";
|
export type { HermesAgentConfig } from "./types.js";
|
||||||
export { validateHermesAgentConfig } from "./validate-config.js";
|
export { validateHermesAgentConfig } from "./validate-config.js";
|
||||||
|
|
||||||
@@ -29,16 +32,10 @@ function throwHermesSpawnError(error: SpawnCliError): never {
|
|||||||
throw new Error("hermes: unknown spawn error");
|
throw new Error("hermes: unknown spawn error");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
|
function createHermesAgentFn(config: HermesAgentConfig): AgentFn<HermesAgentOpt> {
|
||||||
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
|
||||||
const timeoutMs = config.timeout;
|
const timeoutMs = config.timeout;
|
||||||
|
|
||||||
return createTextAdapter(async (ctx, prompt, _runtime) => {
|
return async (ctx, { prompt }) => {
|
||||||
const validated = validateHermesAgentConfig(config);
|
|
||||||
if (!validated.ok) {
|
|
||||||
throw new Error(validated.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const threadInput = await buildThreadInput(ctx);
|
const threadInput = await buildThreadInput(ctx);
|
||||||
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
||||||
const args = [
|
const args = [
|
||||||
@@ -61,5 +58,16 @@ export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
|||||||
throwHermesSpawnError(run.error);
|
throwHermesSpawnError(run.error);
|
||||||
}
|
}
|
||||||
return run.value;
|
return run.value;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
|
||||||
|
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
|
||||||
|
return createAgentAdapter(createHermesAgentFn(config), async (_ctx, prompt, _runtime) => {
|
||||||
|
const validated = validateHermesAgentConfig(config);
|
||||||
|
if (!validated.ok) {
|
||||||
|
throw new Error(validated.error);
|
||||||
|
}
|
||||||
|
return { prompt };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static metadata for @uncaged/workflow-agent-hermes.
|
||||||
|
* Config maps to {@link HermesAgentConfig}.
|
||||||
|
*/
|
||||||
|
export const packageDescriptor: PackageDescriptor = {
|
||||||
|
name: "@uncaged/workflow-agent-hermes",
|
||||||
|
version: "0.5.0-alpha.4",
|
||||||
|
capabilities: ["hermes-cli", "yolo-mode"],
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["command"],
|
||||||
|
properties: {
|
||||||
|
command: {
|
||||||
|
type: "string",
|
||||||
|
description: "Absolute path to the hermes CLI binary.",
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
anyOf: [{ type: "string" }, { type: "null" }],
|
||||||
|
description: "Model identifier passed to hermes --model; null uses the CLI default.",
|
||||||
|
},
|
||||||
|
timeout: {
|
||||||
|
anyOf: [{ type: "number" }, { type: "null" }],
|
||||||
|
description: "Timeout in milliseconds; null means no limit.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,5 +1,41 @@
|
|||||||
# @uncaged/workflow-agent-llm
|
# @uncaged/workflow-agent-llm
|
||||||
|
|
||||||
|
## 0.5.0-alpha.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.4
|
||||||
|
|
||||||
|
## 0.5.0-alpha.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.3
|
||||||
|
|
||||||
|
## 0.5.0-alpha.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.2
|
||||||
|
|
||||||
|
## 0.5.0-alpha.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||||
|
|
||||||
|
## 0.5.0-alpha.0
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.0
|
||||||
|
|
||||||
## 0.4.5
|
## 0.4.5
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { packageDescriptor } from "../src/index.js";
|
||||||
|
|
||||||
|
describe("packageDescriptor", () => {
|
||||||
|
test("has the correct package name", () => {
|
||||||
|
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-llm");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("has a non-empty version string", () => {
|
||||||
|
expect(typeof packageDescriptor.version).toBe("string");
|
||||||
|
expect(packageDescriptor.version.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("capabilities is a non-empty array of strings", () => {
|
||||||
|
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
|
||||||
|
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
|
||||||
|
for (const cap of packageDescriptor.capabilities) {
|
||||||
|
expect(typeof cap).toBe("string");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configSchema is an object with type 'object'", () => {
|
||||||
|
expect(typeof packageDescriptor.configSchema).toBe("object");
|
||||||
|
expect(packageDescriptor.configSchema.type).toBe("object");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configSchema requires baseUrl, apiKey, model", () => {
|
||||||
|
const required = packageDescriptor.configSchema.required as string[];
|
||||||
|
expect(required).toContain("baseUrl");
|
||||||
|
expect(required).toContain("apiKey");
|
||||||
|
expect(required).toContain("model");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configSchema properties include baseUrl, apiKey, model", () => {
|
||||||
|
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
|
||||||
|
expect(props).toHaveProperty("baseUrl");
|
||||||
|
expect(props).toHaveProperty("apiKey");
|
||||||
|
expect(props).toHaveProperty("model");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-agent-llm",
|
"name": "@uncaged/workflow-agent-llm",
|
||||||
"version": "0.4.5",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
"src",
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { type AdapterFn, err, type LlmProvider, ok, type Result } from "@uncaged/workflow-runtime";
|
import {
|
||||||
import { createTextAdapter } from "@uncaged/workflow-util-agent";
|
type AdapterFn,
|
||||||
|
type AgentFn,
|
||||||
|
err,
|
||||||
|
type LlmProvider,
|
||||||
|
ok,
|
||||||
|
type Result,
|
||||||
|
} from "@uncaged/workflow-runtime";
|
||||||
|
import { createAgentAdapter } from "@uncaged/workflow-util-agent";
|
||||||
|
|
||||||
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
|
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
|
||||||
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
||||||
@@ -91,9 +98,10 @@ export async function chatCompletionText(options: {
|
|||||||
return parseAssistantText(res.value);
|
return parseAssistantText(res.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
|
type LlmAgentOpt = { prompt: string };
|
||||||
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
|
|
||||||
return createTextAdapter(async (ctx, prompt, _runtime) => {
|
function createLlmAgent(provider: LlmProvider): AgentFn<LlmAgentOpt> {
|
||||||
|
return async (ctx, { prompt }) => {
|
||||||
const result = await chatCompletionText({
|
const result = await chatCompletionText({
|
||||||
provider,
|
provider,
|
||||||
messages: [
|
messages: [
|
||||||
@@ -105,5 +113,12 @@ export function createLlmAdapter(provider: LlmProvider): AdapterFn {
|
|||||||
throw new Error(`llm: ${formatLlmChatError(result.error)}`);
|
throw new Error(`llm: ${formatLlmChatError(result.error)}`);
|
||||||
}
|
}
|
||||||
return result.value;
|
return result.value;
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-turn chat adapter: system prompt is passed by the workflow engine. */
|
||||||
|
export function createLlmAdapter(provider: LlmProvider): AdapterFn {
|
||||||
|
return createAgentAdapter(createLlmAgent(provider), async (_ctx, prompt, _runtime) => ({
|
||||||
|
prompt,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export {
|
|||||||
type LlmChatError,
|
type LlmChatError,
|
||||||
type LlmMessage,
|
type LlmMessage,
|
||||||
} from "./create-llm-adapter.js";
|
} from "./create-llm-adapter.js";
|
||||||
|
export { packageDescriptor } from "./package-descriptor.js";
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static metadata for @uncaged/workflow-agent-llm.
|
||||||
|
* Config maps to {@link LlmProvider}: baseUrl + apiKey + model.
|
||||||
|
*/
|
||||||
|
export const packageDescriptor: PackageDescriptor = {
|
||||||
|
name: "@uncaged/workflow-agent-llm",
|
||||||
|
version: "0.5.0-alpha.4",
|
||||||
|
capabilities: ["llm-single-turn"],
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["baseUrl", "apiKey", "model"],
|
||||||
|
properties: {
|
||||||
|
baseUrl: {
|
||||||
|
type: "string",
|
||||||
|
description: "Base URL of the OpenAI-compatible chat completions endpoint.",
|
||||||
|
},
|
||||||
|
apiKey: {
|
||||||
|
type: "string",
|
||||||
|
description: "API key for the provider.",
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
type: "string",
|
||||||
|
description: "Model identifier passed as the `model` field in the request body.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,5 +1,51 @@
|
|||||||
# @uncaged/workflow-agent-react
|
# @uncaged/workflow-agent-react
|
||||||
|
|
||||||
|
## 0.5.0-alpha.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [f74b482]
|
||||||
|
- Updated dependencies [f74b482]
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-reactor@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.4
|
||||||
|
|
||||||
|
## 0.5.0-alpha.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-reactor@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.3
|
||||||
|
|
||||||
|
## 0.5.0-alpha.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-reactor@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.2
|
||||||
|
|
||||||
|
## 0.5.0-alpha.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-reactor@0.5.0-alpha.1
|
||||||
|
|
||||||
|
## 0.5.0-alpha.0
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-reactor@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-util-agent@0.5.0-alpha.0
|
||||||
|
|
||||||
## 0.4.5
|
## 0.4.5
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { packageDescriptor } from "../src/index.js";
|
||||||
|
|
||||||
|
describe("packageDescriptor", () => {
|
||||||
|
test("has the correct package name", () => {
|
||||||
|
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-react");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("has a non-empty version string", () => {
|
||||||
|
expect(typeof packageDescriptor.version).toBe("string");
|
||||||
|
expect(packageDescriptor.version.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("capabilities is a non-empty array of strings", () => {
|
||||||
|
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
|
||||||
|
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
|
||||||
|
for (const cap of packageDescriptor.capabilities) {
|
||||||
|
expect(typeof cap).toBe("string");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configSchema is an object with type 'object'", () => {
|
||||||
|
expect(typeof packageDescriptor.configSchema).toBe("object");
|
||||||
|
expect(packageDescriptor.configSchema.type).toBe("object");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configSchema requires maxRounds", () => {
|
||||||
|
const required = packageDescriptor.configSchema.required as string[];
|
||||||
|
expect(required).toContain("maxRounds");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("configSchema properties include maxRounds", () => {
|
||||||
|
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
|
||||||
|
expect(props).toHaveProperty("maxRounds");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-agent-react",
|
"name": "@uncaged/workflow-agent-react",
|
||||||
"version": "0.4.5",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
"src",
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { createReactAdapter } from "./create-react-adapter.js";
|
export { createReactAdapter } from "./create-react-adapter.js";
|
||||||
|
export { packageDescriptor } from "./package-descriptor.js";
|
||||||
export type { ToolEntry, ToolHandler } from "./tools/index.js";
|
export type { ToolEntry, ToolHandler } from "./tools/index.js";
|
||||||
export { defaultToolHandler, defaultTools } from "./tools/index.js";
|
export { defaultToolHandler, defaultTools } from "./tools/index.js";
|
||||||
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
|
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { PackageDescriptor } from "@uncaged/workflow-protocol";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static metadata for @uncaged/workflow-agent-react.
|
||||||
|
*
|
||||||
|
* Config represents the serializable subset of {@link ReactAdapterConfig}.
|
||||||
|
* The `llm` function and `toolHandler` are runtime constructs and are not
|
||||||
|
* stored in the CAS agent node; only `maxRounds` is serializable.
|
||||||
|
*/
|
||||||
|
export const packageDescriptor: PackageDescriptor = {
|
||||||
|
name: "@uncaged/workflow-agent-react",
|
||||||
|
version: "0.5.0-alpha.4",
|
||||||
|
capabilities: ["react-loop", "tool-calling"],
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["maxRounds"],
|
||||||
|
properties: {
|
||||||
|
maxRounds: {
|
||||||
|
type: "number",
|
||||||
|
description: "Maximum number of LLM ↔ tool-call rounds before the loop is terminated.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,5 +1,46 @@
|
|||||||
# @uncaged/workflow-cas
|
# @uncaged/workflow-cas
|
||||||
|
|
||||||
|
## 0.5.0-alpha.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- Updated dependencies [f74b482]
|
||||||
|
- Updated dependencies [f74b482]
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||||
|
|
||||||
|
## 0.5.0-alpha.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.3
|
||||||
|
|
||||||
|
## 0.5.0-alpha.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.2
|
||||||
|
|
||||||
|
## 0.5.0-alpha.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.1
|
||||||
|
|
||||||
|
## 0.5.0-alpha.0
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.0
|
||||||
|
|
||||||
## 0.4.5
|
## 0.4.5
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-cas",
|
"name": "@uncaged/workflow-cas",
|
||||||
"version": "0.4.5",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
"src",
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -1,29 +1,16 @@
|
|||||||
export { createCasStore } from "./cas.js";
|
export { createCasStore } from "./cas.js";
|
||||||
export { collectRefs } from "./collect-refs.js";
|
export { hashWorkflowBundleBytes } from "./hash.js";
|
||||||
export { hashString, hashWorkflowBundleBytes } from "./hash.js";
|
|
||||||
export {
|
export {
|
||||||
createContentMerkleNode,
|
createContentMerkleNode,
|
||||||
getContentMerklePayload,
|
getContentMerklePayload,
|
||||||
parseMerkleNode,
|
|
||||||
putContentMerkleNode,
|
putContentMerkleNode,
|
||||||
putStepMerkleNode,
|
|
||||||
putThreadMerkleNode,
|
|
||||||
serializeMerkleNode,
|
serializeMerkleNode,
|
||||||
} from "./merkle.js";
|
} from "./merkle.js";
|
||||||
export type { ParsedCasThreadNode } from "./nodes.js";
|
|
||||||
export {
|
export {
|
||||||
isCasNodeYaml,
|
|
||||||
parseCasThreadNode,
|
parseCasThreadNode,
|
||||||
putContentNodeWithRefs,
|
putContentNodeWithRefs,
|
||||||
putStartNode,
|
putStartNode,
|
||||||
putStateNode,
|
putStateNode,
|
||||||
serializeCasNode,
|
|
||||||
} from "./nodes.js";
|
} from "./nodes.js";
|
||||||
export { findReachableHashes } from "./reachable.js";
|
export { findReachableHashes } from "./reachable.js";
|
||||||
export type {
|
export type { CasStore } from "./types.js";
|
||||||
CasStore,
|
|
||||||
MerkleNode,
|
|
||||||
MerkleNodeType,
|
|
||||||
StepMerklePayload,
|
|
||||||
ThreadMerklePayload,
|
|
||||||
} from "./types.js";
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist"
|
"outDir": "dist",
|
||||||
|
"composite": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util" }]
|
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util" }]
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ function authHeaders(): Record<string, string> {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function agentBase(agent: string): string {
|
function clientBase(client: string): string {
|
||||||
if (GATEWAY_URL) {
|
if (GATEWAY_URL) {
|
||||||
return `${GATEWAY_URL}/api/agents/${agent}`;
|
return `${GATEWAY_URL}/api/clients/${client}`;
|
||||||
}
|
}
|
||||||
// Local dev: proxy via vite, no agent prefix
|
// Local dev: proxy via vite, no client prefix
|
||||||
return "/api";
|
return "/api";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ async function fetchJson<T>(base: string, path: string): Promise<T> {
|
|||||||
|
|
||||||
// ── Endpoint types ──────────────────────────────────────────────────
|
// ── Endpoint types ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export type AgentEndpoint = {
|
export type ClientEndpoint = {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -122,6 +122,7 @@ export type WorkflowGraph = {
|
|||||||
|
|
||||||
export type WorkflowRoleDescriptor = {
|
export type WorkflowRoleDescriptor = {
|
||||||
description: string;
|
description: string;
|
||||||
|
systemPrompt: string;
|
||||||
schema: Record<string, unknown>;
|
schema: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,61 +142,61 @@ export type WorkflowDetail = {
|
|||||||
|
|
||||||
// ── Gateway endpoints ───────────────────────────────────────────────
|
// ── Gateway endpoints ───────────────────────────────────────────────
|
||||||
|
|
||||||
export function listAgents(): Promise<AgentEndpoint[]> {
|
export function listClients(): Promise<ClientEndpoint[]> {
|
||||||
const url = GATEWAY_URL || "";
|
const url = GATEWAY_URL || "";
|
||||||
return fetchJson(url, "/api/gateway/endpoints");
|
return fetchJson(url, "/api/gateway/endpoints");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Agent-scoped endpoints ──────────────────────────────────────────
|
// ── Client-scoped endpoints ──────────────────────────────────────────
|
||||||
|
|
||||||
export function listWorkflows(agent: string): Promise<{ workflows: WorkflowSummary[] }> {
|
export function listWorkflows(client: string): Promise<{ workflows: WorkflowSummary[] }> {
|
||||||
return fetchJson(agentBase(agent), "/workflows");
|
return fetchJson(clientBase(client), "/workflows");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWorkflowDetail(agent: string, name: string): Promise<WorkflowDetail> {
|
export async function getWorkflowDetail(client: string, name: string): Promise<WorkflowDetail> {
|
||||||
return fetchJson<WorkflowDetail>(agentBase(agent), `/workflows/${encodeURIComponent(name)}`);
|
return fetchJson<WorkflowDetail>(clientBase(client), `/workflows/${encodeURIComponent(name)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWorkflowDescriptor(
|
export async function getWorkflowDescriptor(
|
||||||
agent: string,
|
client: string,
|
||||||
name: string,
|
name: string,
|
||||||
): Promise<WorkflowDescriptor | null> {
|
): Promise<WorkflowDescriptor | null> {
|
||||||
const res = await getWorkflowDetail(agent, name);
|
const res = await getWorkflowDetail(client, name);
|
||||||
return res.descriptor;
|
return res.descriptor;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
|
export function listThreads(client: string): Promise<{ threads: ThreadSummary[] }> {
|
||||||
return fetchJson(agentBase(agent), "/threads");
|
return fetchJson(clientBase(client), "/threads");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listRunningThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
|
export function listRunningThreads(client: string): Promise<{ threads: ThreadSummary[] }> {
|
||||||
return fetchJson(agentBase(agent), "/threads/running");
|
return fetchJson(clientBase(client), "/threads/running");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getThread(agent: string, id: string): Promise<{ records: ThreadRecord[] }> {
|
export function getThread(client: string, id: string): Promise<{ records: ThreadRecord[] }> {
|
||||||
return fetchJson(agentBase(agent), `/threads/${id}`);
|
return fetchJson(clientBase(client), `/threads/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runThread(
|
export function runThread(
|
||||||
agent: string,
|
client: string,
|
||||||
workflow: string,
|
workflow: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
): Promise<{ threadId: string }> {
|
): Promise<{ threadId: string }> {
|
||||||
return postJson(agentBase(agent), "/threads", { workflow, prompt });
|
return postJson(clientBase(client), "/threads", { workflow, prompt });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function killThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
|
export function killThread(client: string, threadId: string): Promise<{ ok: boolean }> {
|
||||||
return postJson(agentBase(agent), `/threads/${threadId}/kill`, {});
|
return postJson(clientBase(client), `/threads/${threadId}/kill`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pauseThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
|
export function pauseThread(client: string, threadId: string): Promise<{ ok: boolean }> {
|
||||||
return postJson(agentBase(agent), `/threads/${threadId}/pause`, {});
|
return postJson(clientBase(client), `/threads/${threadId}/pause`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resumeThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
|
export function resumeThread(client: string, threadId: string): Promise<{ ok: boolean }> {
|
||||||
return postJson(agentBase(agent), `/threads/${threadId}/resume`, {});
|
return postJson(clientBase(client), `/threads/${threadId}/resume`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAgentHealth(agent: string): Promise<{ ok: boolean }> {
|
export function getClientHealth(client: string): Promise<{ ok: boolean }> {
|
||||||
return fetchJson(agentBase(agent), "/healthz");
|
return fetchJson(clientBase(client), "/healthz");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { Sidebar } from "./components/sidebar.tsx";
|
|||||||
import { StatusBar } from "./components/status-bar.tsx";
|
import { StatusBar } from "./components/status-bar.tsx";
|
||||||
import { ThreadDetail } from "./components/thread-detail.tsx";
|
import { ThreadDetail } from "./components/thread-detail.tsx";
|
||||||
import { ThreadList } from "./components/thread-list.tsx";
|
import { ThreadList } from "./components/thread-list.tsx";
|
||||||
|
import { WorkflowDetail } from "./components/workflow-detail.tsx";
|
||||||
import { WorkflowList } from "./components/workflow-list.tsx";
|
import { WorkflowList } from "./components/workflow-list.tsx";
|
||||||
import { useHashRoute } from "./use-hash-route.ts";
|
import { useHashRoute } from "./use-hash-route.ts";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [authed, setAuthed] = useState(hasApiKey());
|
const [authed, setAuthed] = useState(hasApiKey());
|
||||||
const { view, agent, threadId, setView, setAgent, setThreadId } = useHashRoute();
|
const { view, client, threadId, workflowName, setView, setClient, setThreadId, setWorkflowName } =
|
||||||
|
useHashRoute();
|
||||||
const [showRun, setShowRun] = useState(false);
|
const [showRun, setShowRun] = useState(false);
|
||||||
|
|
||||||
if (!authed) {
|
if (!authed) {
|
||||||
@@ -22,36 +24,45 @@ export function App() {
|
|||||||
<div className="flex h-screen">
|
<div className="flex h-screen">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
view={view}
|
view={view}
|
||||||
agent={agent}
|
client={client}
|
||||||
onViewChange={setView}
|
onViewChange={setView}
|
||||||
onAgentChange={setAgent}
|
onClientChange={setClient}
|
||||||
onLogout={() => {
|
onLogout={() => {
|
||||||
clearApiKey();
|
clearApiKey();
|
||||||
setAuthed(false);
|
setAuthed(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<main className="flex-1 overflow-hidden flex flex-col">
|
<main className="flex-1 overflow-hidden flex flex-col">
|
||||||
<StatusBar agent={agent} onRun={() => setShowRun(true)} />
|
<StatusBar client={client} onRun={() => setShowRun(true)} />
|
||||||
<div className="flex-1 overflow-auto p-6">
|
<div className="flex-1 overflow-auto p-6">
|
||||||
{!agent && (
|
{!client && (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<p style={{ color: "var(--color-text-muted)" }}>
|
<p style={{ color: "var(--color-text-muted)" }}>
|
||||||
Select an agent from the sidebar to get started.
|
Select an client from the sidebar to get started.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{agent && view === "threads" && threadId === null && (
|
{client && view === "threads" && threadId === null && (
|
||||||
<ThreadList agent={agent} onSelect={setThreadId} />
|
<ThreadList client={client} onSelect={setThreadId} />
|
||||||
)}
|
)}
|
||||||
{agent && view === "threads" && threadId !== null && (
|
{client && view === "threads" && threadId !== null && (
|
||||||
<ThreadDetail agent={agent} threadId={threadId} onBack={() => setThreadId(null)} />
|
<ThreadDetail client={client} threadId={threadId} onBack={() => setThreadId(null)} />
|
||||||
|
)}
|
||||||
|
{client && view === "workflows" && workflowName === null && (
|
||||||
|
<WorkflowList client={client} onSelect={setWorkflowName} />
|
||||||
|
)}
|
||||||
|
{client && view === "workflows" && workflowName !== null && (
|
||||||
|
<WorkflowDetail
|
||||||
|
client={client}
|
||||||
|
workflowName={workflowName}
|
||||||
|
onBack={() => setWorkflowName(null)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{agent && view === "workflows" && <WorkflowList agent={agent} />}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{showRun && agent && (
|
{showRun && client && (
|
||||||
<RunDialog
|
<RunDialog
|
||||||
agent={agent}
|
client={client}
|
||||||
onClose={() => setShowRun(false)}
|
onClose={() => setShowRun(false)}
|
||||||
onCreated={(id) => {
|
onCreated={(id) => {
|
||||||
setShowRun(false);
|
setShowRun(false);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Markdown } from "./markdown.tsx";
|
|||||||
|
|
||||||
const ROLE_COLORS: Record<string, string> = {
|
const ROLE_COLORS: Record<string, string> = {
|
||||||
preparer: "#8b5cf6",
|
preparer: "#8b5cf6",
|
||||||
agent: "#3b82f6",
|
client: "#3b82f6",
|
||||||
extractor: "#f59e0b",
|
extractor: "#f59e0b",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { listWorkflows, runThread } from "../api.ts";
|
|||||||
import { useFetch } from "../hooks.ts";
|
import { useFetch } from "../hooks.ts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
agent: string;
|
client: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreated: (threadId: string) => void;
|
onCreated: (threadId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RunDialog({ agent, onClose, onCreated }: Props) {
|
export function RunDialog({ client, onClose, onCreated }: Props) {
|
||||||
const workflows = useFetch(() => listWorkflows(agent), [agent]);
|
const workflows = useFetch(() => listWorkflows(client), [client]);
|
||||||
const [workflow, setWorkflow] = useState("");
|
const [workflow, setWorkflow] = useState("");
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@@ -21,7 +21,7 @@ export function RunDialog({ agent, onClose, onCreated }: Props) {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const result = await runThread(agent, workflow, prompt);
|
const result = await runThread(client, workflow, prompt);
|
||||||
onCreated(result.threadId);
|
onCreated(result.threadId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
@@ -38,7 +38,7 @@ export function RunDialog({ agent, onClose, onCreated }: Props) {
|
|||||||
className="w-full max-w-lg p-6 rounded-lg border"
|
className="w-full max-w-lg p-6 rounded-lg border"
|
||||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||||
>
|
>
|
||||||
<h3 className="text-lg font-semibold mb-4">Run Thread on {agent}</h3>
|
<h3 className="text-lg font-semibold mb-4">Run Thread on {client}</h3>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import type { AgentEndpoint } from "../api.ts";
|
import type { ClientEndpoint } from "../api.ts";
|
||||||
import { listAgents } from "../api.ts";
|
import { listClients } from "../api.ts";
|
||||||
import { useFetch } from "../hooks.ts";
|
import { useFetch } from "../hooks.ts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
view: "threads" | "workflows";
|
view: "threads" | "workflows";
|
||||||
agent: string | null;
|
client: string | null;
|
||||||
onViewChange: (v: "threads" | "workflows") => void;
|
onViewChange: (v: "threads" | "workflows") => void;
|
||||||
onAgentChange: (a: string | null) => void;
|
onClientChange: (a: string | null) => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Sidebar({ view, agent, onViewChange, onAgentChange, onLogout }: Props) {
|
export function Sidebar({ view, client, onViewChange, onClientChange, onLogout }: Props) {
|
||||||
const { status, data } = useFetch(() => listAgents(), []);
|
const { status, data } = useFetch(() => listClients(), []);
|
||||||
|
|
||||||
const agents: AgentEndpoint[] = status === "ok" ? data : [];
|
const clients: ClientEndpoint[] = status === "ok" ? data : [];
|
||||||
|
|
||||||
// Auto-select first agent when none is selected
|
// Auto-select first client when none is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (agent === null && agents.length > 0) {
|
if (client === null && clients.length > 0) {
|
||||||
onAgentChange(agents[0].name);
|
onClientChange(clients[0].name);
|
||||||
}
|
}
|
||||||
}, [agent, agents, onAgentChange]);
|
}, [client, clients, onClientChange]);
|
||||||
|
|
||||||
const viewItems = [
|
const viewItems = [
|
||||||
{ key: "threads" as const, label: "Threads", icon: "⚡" },
|
{ key: "threads" as const, label: "Threads", icon: "⚡" },
|
||||||
@@ -42,33 +42,33 @@ export function Sidebar({ view, agent, onViewChange, onAgentChange, onLogout }:
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agent selector */}
|
{/* Client selector */}
|
||||||
<div className="px-4 py-3 border-b" style={{ borderColor: "var(--color-border)" }}>
|
<div className="px-4 py-3 border-b" style={{ borderColor: "var(--color-border)" }}>
|
||||||
<label
|
<label
|
||||||
className="block text-xs font-medium mb-1"
|
className="block text-xs font-medium mb-1"
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
htmlFor="agent-select"
|
htmlFor="client-select"
|
||||||
>
|
>
|
||||||
Agent
|
Client
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="agent-select"
|
id="client-select"
|
||||||
className="w-full rounded px-2 py-1.5 text-xs"
|
className="w-full rounded px-2 py-1.5 text-xs"
|
||||||
style={{
|
style={{
|
||||||
background: "var(--color-bg)",
|
background: "var(--color-bg)",
|
||||||
color: "var(--color-text)",
|
color: "var(--color-text)",
|
||||||
border: "1px solid var(--color-border)",
|
border: "1px solid var(--color-border)",
|
||||||
}}
|
}}
|
||||||
value={agent ?? ""}
|
value={client ?? ""}
|
||||||
onChange={(e) => onAgentChange(e.target.value || null)}
|
onChange={(e) => onClientChange(e.target.value || null)}
|
||||||
disabled={status === "loading"}
|
disabled={status === "loading"}
|
||||||
>
|
>
|
||||||
{status === "loading" ? (
|
{status === "loading" ? (
|
||||||
<option value="">Loading…</option>
|
<option value="">Loading…</option>
|
||||||
) : agents.length === 0 ? (
|
) : clients.length === 0 ? (
|
||||||
<option value="">No agents online</option>
|
<option value="">No clients online</option>
|
||||||
) : (
|
) : (
|
||||||
agents.map((a) => (
|
clients.map((a) => (
|
||||||
<option key={a.name} value={a.name}>
|
<option key={a.name} value={a.name}>
|
||||||
{a.status === "online" ? "🟢" : "🔴"} {a.name}
|
{a.status === "online" ? "🟢" : "🔴"} {a.name}
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { getAgentHealth } from "../api.ts";
|
import { getClientHealth } from "../api.ts";
|
||||||
|
|
||||||
type HealthStatus = "connected" | "disconnected" | "reconnecting";
|
type HealthStatus = "connected" | "disconnected" | "reconnecting";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
agent: string | null;
|
client: string | null;
|
||||||
onRun: () => void;
|
onRun: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -18,17 +18,17 @@ function statusLabel(status: HealthStatus): { text: string; color: string } {
|
|||||||
return { text: "● Offline", color: "var(--color-error)" };
|
return { text: "● Offline", color: "var(--color-error)" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusBar({ agent, onRun }: Props) {
|
export function StatusBar({ client, onRun }: Props) {
|
||||||
const [status, setStatus] = useState<HealthStatus>("disconnected");
|
const [status, setStatus] = useState<HealthStatus>("disconnected");
|
||||||
const wasConnectedRef = useRef(false);
|
const wasConnectedRef = useRef(false);
|
||||||
|
|
||||||
const checkHealth = useCallback(async () => {
|
const checkHealth = useCallback(async () => {
|
||||||
if (!agent) {
|
if (!client) {
|
||||||
setStatus("disconnected");
|
setStatus("disconnected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await getAgentHealth(agent);
|
await getClientHealth(client);
|
||||||
wasConnectedRef.current = true;
|
wasConnectedRef.current = true;
|
||||||
setStatus("connected");
|
setStatus("connected");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -38,7 +38,7 @@ export function StatusBar({ agent, onRun }: Props) {
|
|||||||
setStatus("disconnected");
|
setStatus("disconnected");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [agent]);
|
}, [client]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
wasConnectedRef.current = false;
|
wasConnectedRef.current = false;
|
||||||
@@ -57,17 +57,17 @@ export function StatusBar({ agent, onRun }: Props) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span style={{ color: "var(--color-text-muted)" }}>
|
<span style={{ color: "var(--color-text-muted)" }}>
|
||||||
{agent ? `Agent: ${agent}` : "No agent selected"}
|
{client ? `Client: ${client}` : "No client selected"}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRun}
|
onClick={onRun}
|
||||||
disabled={!agent}
|
disabled={!client}
|
||||||
className="px-3 py-1 rounded text-xs font-medium"
|
className="px-3 py-1 rounded text-xs font-medium"
|
||||||
style={{
|
style={{
|
||||||
background: agent ? "var(--color-accent)" : "var(--color-border)",
|
background: client ? "var(--color-accent)" : "var(--color-border)",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
opacity: agent ? 1 : 0.5,
|
opacity: client ? 1 : 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
▶ Run Thread
|
▶ Run Thread
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { RecordCard } from "./record-card.tsx";
|
|||||||
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
agent: string;
|
client: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
};
|
};
|
||||||
@@ -39,7 +39,8 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
|
|||||||
states.set(role, !hasResult && isLast ? "active" : "completed");
|
states.set(role, !hasResult && isLast ? "active" : "completed");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (roleRecords.length > 0) {
|
const hasStart = records.some((r) => r.type === "thread-start");
|
||||||
|
if (hasStart) {
|
||||||
states.set("__start__", "completed");
|
states.set("__start__", "completed");
|
||||||
}
|
}
|
||||||
if (hasResult) {
|
if (hasResult) {
|
||||||
@@ -52,9 +53,38 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
|
|||||||
return states;
|
return states;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
function isClickableGraphNode(nodeStates: Map<string, NodeState>, nodeId: string): boolean {
|
||||||
const sse = useSSE(agent, threadId);
|
const state = nodeStates.get(nodeId);
|
||||||
const { status, data, error } = useFetch(() => getThread(agent, threadId), [agent, threadId]);
|
return state !== undefined && state !== "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToFirstRecord(): void {
|
||||||
|
const firstCard = document.querySelector('[data-record-index="0"]');
|
||||||
|
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToRoleOccurrence(
|
||||||
|
nodeId: string,
|
||||||
|
indicesByRole: Map<string, number[]>,
|
||||||
|
clickCycleRef: { current: Map<string, number> },
|
||||||
|
onHighlight: (role: string) => void,
|
||||||
|
): void {
|
||||||
|
const indices = indicesByRole.get(nodeId);
|
||||||
|
if (indices === undefined || indices.length === 0) return;
|
||||||
|
|
||||||
|
const cycle = clickCycleRef.current.get(nodeId) ?? 0;
|
||||||
|
const idx = indices[cycle % indices.length];
|
||||||
|
clickCycleRef.current.set(nodeId, cycle + 1);
|
||||||
|
|
||||||
|
const el = document.querySelector(`[data-record-index="${idx}"]`);
|
||||||
|
if (el === null) return;
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
onHighlight(nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||||
|
const sse = useSSE(client, threadId);
|
||||||
|
const { status, data, error } = useFetch(() => getThread(client, threadId), [client, threadId]);
|
||||||
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
||||||
const recordsEndRef = useRef<HTMLDivElement>(null);
|
const recordsEndRef = useRef<HTMLDivElement>(null);
|
||||||
const firstCardByRoleRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
const firstCardByRoleRef = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
@@ -72,36 +102,54 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
|||||||
|
|
||||||
const descriptorFetch = useFetch<WorkflowDescriptor | null>(
|
const descriptorFetch = useFetch<WorkflowDescriptor | null>(
|
||||||
() =>
|
() =>
|
||||||
workflowName === null ? Promise.resolve(null) : getWorkflowDescriptor(agent, workflowName),
|
workflowName === null ? Promise.resolve(null) : getWorkflowDescriptor(client, workflowName),
|
||||||
[agent, workflowName],
|
[client, workflowName],
|
||||||
);
|
);
|
||||||
|
|
||||||
const descriptor = descriptorFetch.status === "ok" ? descriptorFetch.data : null;
|
const descriptor = descriptorFetch.status === "ok" ? descriptorFetch.data : null;
|
||||||
const nodeStates = useMemo(() => computeNodeStates(records), [records]);
|
const nodeStates = useMemo(() => computeNodeStates(records), [records]);
|
||||||
|
|
||||||
const firstIndexByRole = useMemo(() => {
|
const indicesByRole = useMemo(() => {
|
||||||
const m = new Map<string, number>();
|
const m = new Map<string, number[]>();
|
||||||
for (let i = 0; i < records.length; i++) {
|
for (let i = 0; i < records.length; i++) {
|
||||||
const r = records[i];
|
const r = records[i];
|
||||||
if (r.type === "role" && !m.has(r.role)) {
|
if (r.type === "role") {
|
||||||
m.set(r.role, i);
|
const list = m.get(r.role) ?? [];
|
||||||
|
list.push(i);
|
||||||
|
m.set(r.role, list);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m;
|
return m;
|
||||||
}, [records]);
|
}, [records]);
|
||||||
|
|
||||||
const handleGraphNodeClick = useCallback((roleName: string) => {
|
// Track which occurrence to jump to next per role (cycling)
|
||||||
const el = firstCardByRoleRef.current.get(roleName);
|
const clickCycleRef = useRef<Map<string, number>>(new Map());
|
||||||
if (el == null) return;
|
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
const highlightRole = useCallback((role: string) => {
|
||||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||||
setHighlightedRole(roleName);
|
setHighlightedRole(role);
|
||||||
highlightTimerRef.current = setTimeout(() => {
|
highlightTimerRef.current = setTimeout(() => {
|
||||||
setHighlightedRole(null);
|
setHighlightedRole(null);
|
||||||
highlightTimerRef.current = null;
|
highlightTimerRef.current = null;
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleGraphNodeClick = useCallback(
|
||||||
|
(nodeId: string) => {
|
||||||
|
if (!isClickableGraphNode(nodeStates, nodeId)) return;
|
||||||
|
if (nodeId === "__start__") {
|
||||||
|
scrollToFirstRecord();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nodeId === "__end__") {
|
||||||
|
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scrollToRoleOccurrence(nodeId, indicesByRole, clickCycleRef, highlightRole);
|
||||||
|
},
|
||||||
|
[nodeStates, indicesByRole, highlightRole],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||||
@@ -117,7 +165,7 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
|||||||
setActionStatus(`${action}ing...`);
|
setActionStatus(`${action}ing...`);
|
||||||
try {
|
try {
|
||||||
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
|
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
|
||||||
await fn(agent, threadId);
|
await fn(client, threadId);
|
||||||
setActionStatus(`${action} sent ✓`);
|
setActionStatus(`${action} sent ✓`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
@@ -237,11 +285,13 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
|||||||
{records.map((r, i) => {
|
{records.map((r, i) => {
|
||||||
const key = `${threadId}-${i}`;
|
const key = `${threadId}-${i}`;
|
||||||
if (r.type === "role") {
|
if (r.type === "role") {
|
||||||
const isFirstForRole = firstIndexByRole.get(r.role) === i;
|
const roleIndices = indicesByRole.get(r.role);
|
||||||
|
const isFirstForRole = roleIndices !== undefined && roleIndices[0] === i;
|
||||||
const flash = highlightedRole === r.role;
|
const flash = highlightedRole === r.role;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
|
data-record-index={i}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
if (!isFirstForRole) return;
|
if (!isFirstForRole) return;
|
||||||
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
|
if (el !== null) firstCardByRoleRef.current.set(r.role, el);
|
||||||
@@ -252,7 +302,11 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <RecordCard key={key} record={r} highlighted={false} />;
|
return (
|
||||||
|
<div key={key} data-record-index={i}>
|
||||||
|
<RecordCard record={r} highlighted={false} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
<div ref={recordsEndRef} aria-hidden />
|
<div ref={recordsEndRef} aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { listThreads } from "../api.ts";
|
|||||||
import { useFetch } from "../hooks.ts";
|
import { useFetch } from "../hooks.ts";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
agent: string;
|
client: string;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ThreadList({ agent, onSelect }: Props) {
|
export function ThreadList({ client, onSelect }: Props) {
|
||||||
const { status, data, error } = useFetch(() => listThreads(agent), [agent]);
|
const { status, data, error } = useFetch(() => listThreads(client), [client]);
|
||||||
|
|
||||||
if (status === "loading")
|
if (status === "loading")
|
||||||
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
|
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import type { WorkflowDetail as WorkflowDetailData, WorkflowRoleDescriptor } from "../api.ts";
|
||||||
|
import { getWorkflowDetail } from "../api.ts";
|
||||||
|
import { useFetch } from "../hooks.ts";
|
||||||
|
import { Markdown } from "./markdown.tsx";
|
||||||
|
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
client: string;
|
||||||
|
workflowName: string;
|
||||||
|
onBack: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function versionCount(detail: WorkflowDetailData): number {
|
||||||
|
return detail.history.length + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schema rendering helpers ────────────────────────────────────────
|
||||||
|
|
||||||
|
type SchemaRow = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
depth: number;
|
||||||
|
prefix: string;
|
||||||
|
isVariantHeader: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveType(prop: Record<string, unknown>): string {
|
||||||
|
if (prop.type === "array") {
|
||||||
|
const items = prop.items as Record<string, unknown> | undefined;
|
||||||
|
if (items !== undefined) {
|
||||||
|
const itemType = String(items.type ?? "unknown");
|
||||||
|
return `${itemType}[]`;
|
||||||
|
}
|
||||||
|
return "array";
|
||||||
|
}
|
||||||
|
return String(prop.type ?? "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
function variantLabel(
|
||||||
|
variantProps: Record<string, Record<string, unknown>>,
|
||||||
|
variantIndex: number,
|
||||||
|
): string {
|
||||||
|
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||||
|
if (pDef.const !== undefined) return `${pName}: ${String(pDef.const)}`;
|
||||||
|
}
|
||||||
|
return `Variant ${variantIndex + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function childPrefixForDepth(depth: number, parentPrefix: string): string {
|
||||||
|
return depth > 0 ? `${parentPrefix} ` : " ";
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenOneOfVariants(
|
||||||
|
oneOf: Array<Record<string, unknown>>,
|
||||||
|
depth: number,
|
||||||
|
parentPrefix: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
): SchemaRow[] {
|
||||||
|
const rows: SchemaRow[] = [];
|
||||||
|
for (let vi = 0; vi < oneOf.length; vi++) {
|
||||||
|
const variant = oneOf[vi];
|
||||||
|
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||||
|
const isLast = vi === oneOf.length - 1;
|
||||||
|
const connector = isLast ? "└" : "├";
|
||||||
|
rows.push({
|
||||||
|
key: `${keyPrefix}variant-${vi}`,
|
||||||
|
name: `${parentPrefix}${connector} ${variantLabel(variantProps, vi)}`,
|
||||||
|
type: "",
|
||||||
|
description: "",
|
||||||
|
depth,
|
||||||
|
prefix: parentPrefix,
|
||||||
|
isVariantHeader: true,
|
||||||
|
});
|
||||||
|
const variantChildPrefix = `${parentPrefix}${isLast ? " " : "│ "}`;
|
||||||
|
const variantRequired = new Set<string>(
|
||||||
|
Array.isArray(variant.required) ? (variant.required as string[]) : [],
|
||||||
|
);
|
||||||
|
for (const [pName, pDef] of Object.entries(variantProps)) {
|
||||||
|
if (pDef.const !== undefined) continue;
|
||||||
|
rows.push(
|
||||||
|
...flattenProperty(
|
||||||
|
pName,
|
||||||
|
pDef,
|
||||||
|
depth + 1,
|
||||||
|
variantChildPrefix,
|
||||||
|
`${keyPrefix}v${vi}-`,
|
||||||
|
variantRequired,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenSchemaProperties(
|
||||||
|
schema: Record<string, unknown>,
|
||||||
|
depth: number,
|
||||||
|
parentPrefix: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
): SchemaRow[] {
|
||||||
|
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||||
|
const required = new Set<string>(
|
||||||
|
Array.isArray(schema.required) ? (schema.required as string[]) : [],
|
||||||
|
);
|
||||||
|
const rows: SchemaRow[] = [];
|
||||||
|
for (const [name, prop] of Object.entries(props)) {
|
||||||
|
rows.push(...flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenSchema(
|
||||||
|
schema: Record<string, unknown>,
|
||||||
|
depth: number,
|
||||||
|
parentPrefix: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
): SchemaRow[] {
|
||||||
|
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
|
||||||
|
if (Array.isArray(oneOf) && oneOf.length > 0) {
|
||||||
|
return flattenOneOfVariants(oneOf, depth, parentPrefix, keyPrefix);
|
||||||
|
}
|
||||||
|
return flattenSchemaProperties(schema, depth, parentPrefix, keyPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenNestedPropertyRows(
|
||||||
|
name: string,
|
||||||
|
prop: Record<string, unknown>,
|
||||||
|
depth: number,
|
||||||
|
parentPrefix: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
hasOneOf: boolean,
|
||||||
|
): SchemaRow[] {
|
||||||
|
const childPrefix = childPrefixForDepth(depth, parentPrefix);
|
||||||
|
const nestedKeyPrefix = `${keyPrefix}${name}-`;
|
||||||
|
|
||||||
|
if (prop.type === "object" && prop.properties !== undefined) {
|
||||||
|
return flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, nestedKeyPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prop.type === "array") {
|
||||||
|
const items = prop.items as Record<string, unknown> | undefined;
|
||||||
|
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
|
||||||
|
return flattenSchema(items, depth + 1, childPrefix, nestedKeyPrefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasOneOf) {
|
||||||
|
return flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, nestedKeyPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenProperty(
|
||||||
|
name: string,
|
||||||
|
prop: Record<string, unknown>,
|
||||||
|
depth: number,
|
||||||
|
parentPrefix: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
required: Set<string>,
|
||||||
|
): SchemaRow[] {
|
||||||
|
const hasOneOf = Array.isArray(prop.oneOf) && (prop.oneOf as unknown[]).length > 0;
|
||||||
|
let type = hasOneOf ? "⊕ oneOf" : resolveType(prop);
|
||||||
|
if (!required.has(name)) type += "?";
|
||||||
|
|
||||||
|
const rows: SchemaRow[] = [
|
||||||
|
{
|
||||||
|
key: `${keyPrefix}${name}`,
|
||||||
|
name: depth > 0 ? `${parentPrefix}└─ ${name}` : name,
|
||||||
|
type,
|
||||||
|
description: String(prop.description ?? ""),
|
||||||
|
depth,
|
||||||
|
prefix: parentPrefix,
|
||||||
|
isVariantHeader: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
rows.push(...flattenNestedPropertyRows(name, prop, depth, parentPrefix, keyPrefix, hasOneOf));
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Components ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RoleCard({ roleName, role }: { roleName: string; role: WorkflowRoleDescriptor }) {
|
||||||
|
const rows = flattenSchema(role.schema, 0, "", `${roleName}-`);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={`role-${roleName}`}
|
||||||
|
className="rounded-lg border p-4"
|
||||||
|
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
<h4 className="text-sm font-semibold font-mono mb-1" style={{ color: "var(--color-text)" }}>
|
||||||
|
{roleName}
|
||||||
|
</h4>
|
||||||
|
{role.description !== "" && (
|
||||||
|
<p className="text-xs mb-3" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{role.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{role.systemPrompt !== "" && (
|
||||||
|
<details className="mb-3">
|
||||||
|
<summary
|
||||||
|
className="text-[10px] uppercase tracking-wider font-medium cursor-pointer select-none"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
System Prompt
|
||||||
|
</summary>
|
||||||
|
<div
|
||||||
|
className="mt-1 p-2 rounded overflow-y-auto text-xs"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
maxHeight: "300px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Markdown content={role.systemPrompt} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className="text-[10px] uppercase tracking-wider mb-1 font-medium"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Meta Schema
|
||||||
|
</p>
|
||||||
|
<table className="w-full text-xs" style={{ borderCollapse: "collapse" }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: "1px solid var(--color-border)" }}>
|
||||||
|
<th
|
||||||
|
className="text-left py-1 pr-3 font-medium"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Field
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="text-left py-1 pr-3 font-medium"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="text-left py-1 font-medium"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Description
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr
|
||||||
|
key={r.key}
|
||||||
|
style={{
|
||||||
|
borderBottom: r.isVariantHeader ? "none" : "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className="py-1 pr-3 font-mono whitespace-pre"
|
||||||
|
style={{
|
||||||
|
color: r.isVariantHeader ? "var(--color-text-muted)" : "var(--color-accent)",
|
||||||
|
fontStyle: r.isVariantHeader ? "italic" : "normal",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.name}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 pr-3 font-mono" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{r.type}
|
||||||
|
</td>
|
||||||
|
<td className="py-1" style={{ color: "var(--color-text)" }}>
|
||||||
|
{r.description || (r.isVariantHeader ? "" : "—")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rows.length === 0 && Object.keys(role.schema).length > 0 && (
|
||||||
|
<pre
|
||||||
|
className="text-[10px] font-mono p-2 rounded overflow-x-auto"
|
||||||
|
style={{ background: "var(--color-bg)", color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{JSON.stringify(role.schema, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function WorkflowDetail({ client, workflowName, onBack }: Props) {
|
||||||
|
const { status, data, error } = useFetch(
|
||||||
|
() => getWorkflowDetail(client, workflowName),
|
||||||
|
[client, workflowName],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [highlightedRole, setHighlightedRole] = useState<string | null>(null);
|
||||||
|
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const detail = status === "ok" ? data : null;
|
||||||
|
const descriptor = detail?.descriptor ?? null;
|
||||||
|
const roleEntries = descriptor !== null ? Object.entries(descriptor.roles) : [];
|
||||||
|
const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
|
||||||
|
const hasGraph = descriptor !== null && edgeCount > 0;
|
||||||
|
|
||||||
|
const allLitStates = useMemo(() => {
|
||||||
|
const m = new Map<string, NodeState>();
|
||||||
|
m.set("__start__", "completed");
|
||||||
|
m.set("__end__", "completed");
|
||||||
|
for (const [name] of roleEntries) {
|
||||||
|
m.set(name, "completed");
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [roleEntries]);
|
||||||
|
|
||||||
|
function handleGraphNodeClick(nodeId: string) {
|
||||||
|
const el = document.getElementById(`role-${nodeId}`);
|
||||||
|
if (el === null) return;
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
|
||||||
|
setHighlightedRole(nodeId);
|
||||||
|
highlightTimerRef.current = setTimeout(() => {
|
||||||
|
setHighlightedRole(null);
|
||||||
|
highlightTimerRef.current = null;
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className="text-sm hover:underline"
|
||||||
|
style={{ color: "var(--color-accent)" }}
|
||||||
|
>
|
||||||
|
← Back to workflows
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-semibold mb-4 font-mono">{workflowName}</h2>
|
||||||
|
|
||||||
|
{status === "loading" && <p style={{ color: "var(--color-text-muted)" }}>Loading...</p>}
|
||||||
|
{status === "error" && <p style={{ color: "var(--color-error)" }}>Error: {error}</p>}
|
||||||
|
|
||||||
|
{detail !== null && (
|
||||||
|
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 160px)" }}>
|
||||||
|
{/* Left: fixed graph sidebar */}
|
||||||
|
{hasGraph && (
|
||||||
|
<div
|
||||||
|
className="shrink-0"
|
||||||
|
style={{
|
||||||
|
width: 280,
|
||||||
|
position: "sticky",
|
||||||
|
top: 16,
|
||||||
|
height: "calc(100vh - 160px)",
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-lg border h-full flex flex-col overflow-hidden"
|
||||||
|
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-3 py-2 text-xs"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
<span className="font-mono">Workflow graph</span>
|
||||||
|
<span>
|
||||||
|
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<WorkflowGraph
|
||||||
|
graph={descriptor.graph}
|
||||||
|
roles={descriptor.roles}
|
||||||
|
nodeStates={allLitStates}
|
||||||
|
onNodeClick={handleGraphNodeClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Right: scrollable content */}
|
||||||
|
<div className="flex-1 min-w-0 space-y-4">
|
||||||
|
{/* Workflow overview */}
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-4"
|
||||||
|
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-sm whitespace-pre-wrap mb-3"
|
||||||
|
style={{ color: "var(--color-text)" }}
|
||||||
|
>
|
||||||
|
{descriptor !== null && descriptor.description !== ""
|
||||||
|
? descriptor.description
|
||||||
|
: "—"}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4 text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
<span>
|
||||||
|
Hash:{" "}
|
||||||
|
<code className="font-mono" style={{ color: "var(--color-accent)" }}>
|
||||||
|
{detail.hash}
|
||||||
|
</code>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{versionCount(detail)} version{versionCount(detail) !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
{roleEntries.length > 0 && (
|
||||||
|
<span>
|
||||||
|
{roleEntries.length} role{roleEntries.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role cards */}
|
||||||
|
{roleEntries.map(([name, role]) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
style={{
|
||||||
|
transition: "box-shadow 0.3s",
|
||||||
|
boxShadow: highlightedRole === name ? "0 0 0 2px var(--color-accent)" : "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RoleCard roleName={name} role={role} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,30 +2,40 @@ import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from "
|
|||||||
import type { ConditionEdgeData } from "./types.ts";
|
import type { ConditionEdgeData } from "./types.ts";
|
||||||
|
|
||||||
// Must match the FEEDBACK_OFFSET_X in use-layout.ts
|
// Must match the FEEDBACK_OFFSET_X in use-layout.ts
|
||||||
const FEEDBACK_OFFSET_X = 100;
|
const FEEDBACK_OFFSET_X = 80;
|
||||||
// Radius for feedback edge corners
|
// Radius for feedback edge corners
|
||||||
const FEEDBACK_RADIUS = 16;
|
const FEEDBACK_RADIUS = 16;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build an SVG path for a feedback (back) edge that routes to the right of the nodes.
|
* Build an SVG path for an edge routed to the side of the nodes.
|
||||||
* The path goes: source right → arc → vertical up → arc → target right
|
* Works for both feedback (bottom→up) and skip-forward (top→down) edges.
|
||||||
|
* The path goes: source → horizontal to side → vertical → horizontal to target
|
||||||
*/
|
*/
|
||||||
function feedbackPath(sourceX: number, sourceY: number, targetX: number, targetY: number): string {
|
function sidePath(
|
||||||
const rightX = Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X;
|
sourceX: number,
|
||||||
|
sourceY: number,
|
||||||
|
targetX: number,
|
||||||
|
targetY: number,
|
||||||
|
side: "right" | "left",
|
||||||
|
): string {
|
||||||
|
const d = side === "right" ? 1 : -1;
|
||||||
|
const offsetX =
|
||||||
|
side === "right"
|
||||||
|
? Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X
|
||||||
|
: Math.min(sourceX, targetX) - FEEDBACK_OFFSET_X;
|
||||||
const r = FEEDBACK_RADIUS;
|
const r = FEEDBACK_RADIUS;
|
||||||
|
|
||||||
// Start from source right side, go right, then up, then left to target right side
|
// Direction: going up (feedback) or down (skip-forward)
|
||||||
|
const goingDown = targetY > sourceY;
|
||||||
|
const vertSourceY = goingDown ? sourceY + r : sourceY - r;
|
||||||
|
const vertTargetY = goingDown ? targetY - r : targetY + r;
|
||||||
|
|
||||||
const segments = [
|
const segments = [
|
||||||
`M ${sourceX} ${sourceY}`,
|
`M ${sourceX} ${sourceY}`,
|
||||||
// Horizontal to the right
|
`L ${offsetX - d * r} ${sourceY}`,
|
||||||
`L ${rightX - r} ${sourceY}`,
|
`Q ${offsetX} ${sourceY} ${offsetX} ${vertSourceY}`,
|
||||||
// Arc turning upward
|
`L ${offsetX} ${vertTargetY}`,
|
||||||
`Q ${rightX} ${sourceY} ${rightX} ${sourceY - r}`,
|
`Q ${offsetX} ${targetY} ${offsetX - d * r} ${targetY}`,
|
||||||
// Vertical upward
|
|
||||||
`L ${rightX} ${targetY + r}`,
|
|
||||||
// Arc turning left
|
|
||||||
`Q ${rightX} ${targetY} ${rightX - r} ${targetY}`,
|
|
||||||
// Horizontal left to target
|
|
||||||
`L ${targetX} ${targetY}`,
|
`L ${targetX} ${targetY}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -57,10 +67,13 @@ export function ConditionEdge(props: EdgeProps) {
|
|||||||
let defaultLabelY: number;
|
let defaultLabelY: number;
|
||||||
|
|
||||||
if (isFeedback) {
|
if (isFeedback) {
|
||||||
// Custom feedback path routed to the right
|
const side = edgeData?.feedbackSide ?? "right";
|
||||||
path = feedbackPath(sourceX, sourceY, targetX, targetY);
|
path = sidePath(sourceX, sourceY, targetX, targetY, side);
|
||||||
const rightX = Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X;
|
const offsetX =
|
||||||
defaultLabelX = rightX;
|
side === "right"
|
||||||
|
? Math.max(sourceX, targetX) + FEEDBACK_OFFSET_X
|
||||||
|
: Math.min(sourceX, targetX) - FEEDBACK_OFFSET_X;
|
||||||
|
defaultLabelX = offsetX;
|
||||||
defaultLabelY = (sourceY + targetY) / 2;
|
defaultLabelY = (sourceY + targetY) / 2;
|
||||||
} else {
|
} else {
|
||||||
const result = getSmoothStepPath({
|
const result = getSmoothStepPath({
|
||||||
@@ -78,9 +91,8 @@ export function ConditionEdge(props: EdgeProps) {
|
|||||||
defaultLabelY = result[2];
|
defaultLabelY = result[2];
|
||||||
}
|
}
|
||||||
|
|
||||||
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-accent)";
|
const stroke = "var(--color-accent)";
|
||||||
const strokeDasharray = isFallback ? "5 4" : undefined;
|
const label = isFallback ? "" : (edgeData?.condition ?? "");
|
||||||
const label = edgeData?.condition ?? "";
|
|
||||||
|
|
||||||
// Use pre-computed label position if available, otherwise fall back to default
|
// Use pre-computed label position if available, otherwise fall back to default
|
||||||
const labelX = edgeData?.labelX ?? defaultLabelX;
|
const labelX = edgeData?.labelX ?? defaultLabelX;
|
||||||
@@ -88,12 +100,7 @@ export function ConditionEdge(props: EdgeProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BaseEdge
|
<BaseEdge id={id} path={path} markerEnd={markerEnd} style={{ stroke, strokeWidth: 1.5 }} />
|
||||||
id={id}
|
|
||||||
path={path}
|
|
||||||
markerEnd={markerEnd}
|
|
||||||
style={{ stroke, strokeWidth: 1.5, strokeDasharray }}
|
|
||||||
/>
|
|
||||||
{label !== "" && (
|
{label !== "" && (
|
||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
<div
|
<div
|
||||||
@@ -102,7 +109,7 @@ export function ConditionEdge(props: EdgeProps) {
|
|||||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||||
background: "var(--color-surface)",
|
background: "var(--color-surface)",
|
||||||
border: "1px solid var(--color-border)",
|
border: "1px solid var(--color-border)",
|
||||||
color: isFallback ? "var(--color-text-muted)" : "var(--color-text)",
|
color: "var(--color-text)",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function RoleNode(props: NodeProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`px-3 py-2 rounded-md border-2 text-xs font-medium cursor-pointer ${isActive ? "wf-node-pulse" : ""}`}
|
className={`px-3 py-2 rounded-md border-2 text-xs font-medium ${data.state !== "default" ? "cursor-pointer" : ""} ${isActive ? "wf-node-pulse" : ""}`}
|
||||||
style={{
|
style={{
|
||||||
width: 180,
|
width: 180,
|
||||||
height: 60,
|
height: 60,
|
||||||
@@ -45,7 +45,41 @@ export function RoleNode(props: NodeProps) {
|
|||||||
}}
|
}}
|
||||||
title={data.description}
|
title={data.description}
|
||||||
>
|
>
|
||||||
<Handle type="target" position={Position.Top} style={handleStyle} isConnectable={false} />
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
id="top-in"
|
||||||
|
style={handleStyle}
|
||||||
|
isConnectable={false}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
id="left-in"
|
||||||
|
style={handleStyle}
|
||||||
|
isConnectable={false}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Right}
|
||||||
|
id="right-in"
|
||||||
|
style={handleStyle}
|
||||||
|
isConnectable={false}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Left}
|
||||||
|
id="left-out"
|
||||||
|
style={handleStyle}
|
||||||
|
isConnectable={false}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
id="right-out"
|
||||||
|
style={handleStyle}
|
||||||
|
isConnectable={false}
|
||||||
|
/>
|
||||||
<div className="flex items-center gap-1.5 font-mono">
|
<div className="flex items-center gap-1.5 font-mono">
|
||||||
{icon !== null && (
|
{icon !== null && (
|
||||||
<span
|
<span
|
||||||
@@ -63,7 +97,13 @@ export function RoleNode(props: NodeProps) {
|
|||||||
{data.description}
|
{data.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Handle type="source" position={Position.Bottom} style={handleStyle} isConnectable={false} />
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id="bottom-out"
|
||||||
|
style={handleStyle}
|
||||||
|
isConnectable={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function TerminalNode(props: NodeProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`rounded-full border-2 flex items-center justify-center text-[10px] font-bold ${isActive ? "wf-node-pulse" : ""}`}
|
className={`rounded-full border-2 flex items-center justify-center text-[10px] font-bold ${isActive ? "wf-node-pulse" : ""} ${data.state !== "default" ? "cursor-pointer" : ""}`}
|
||||||
style={{
|
style={{
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
@@ -45,11 +45,34 @@ export function TerminalNode(props: NodeProps) {
|
|||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Bottom}
|
position={Position.Bottom}
|
||||||
|
id="bottom-out"
|
||||||
style={handleStyle}
|
style={handleStyle}
|
||||||
isConnectable={false}
|
isConnectable={false}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Handle type="target" position={Position.Top} style={handleStyle} isConnectable={false} />
|
<>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
id="top-in"
|
||||||
|
style={handleStyle}
|
||||||
|
isConnectable={false}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
id="left-in"
|
||||||
|
style={handleStyle}
|
||||||
|
isConnectable={false}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Right}
|
||||||
|
id="right-in"
|
||||||
|
style={handleStyle}
|
||||||
|
isConnectable={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{isStart ? "▶" : "■"}
|
{isStart ? "▶" : "■"}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type ConditionEdgeData = {
|
|||||||
isFallback: boolean;
|
isFallback: boolean;
|
||||||
isFeedback: boolean;
|
isFeedback: boolean;
|
||||||
isSelfLoop: boolean;
|
isSelfLoop: boolean;
|
||||||
|
feedbackSide: "right" | "left" | null;
|
||||||
labelX: number | null;
|
labelX: number | null;
|
||||||
labelY: number | null;
|
labelY: number | null;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const TERMINAL_NODE_SIZE = 40;
|
|||||||
// Vertical gap between nodes in the spine
|
// Vertical gap between nodes in the spine
|
||||||
const LAYER_GAP = 80;
|
const LAYER_GAP = 80;
|
||||||
// Horizontal offset for feedback (back) edges routed on the right side
|
// Horizontal offset for feedback (back) edges routed on the right side
|
||||||
const FEEDBACK_OFFSET_X = 100;
|
const FEEDBACK_OFFSET_X = 80;
|
||||||
|
|
||||||
type LayoutInput = {
|
type LayoutInput = {
|
||||||
edges: readonly WorkflowGraphEdge[];
|
edges: readonly WorkflowGraphEdge[];
|
||||||
@@ -36,76 +36,150 @@ function edgeKey(e: WorkflowGraphEdge): string {
|
|||||||
return `${e.from}->${e.to}::${e.condition}`;
|
return `${e.from}->${e.to}::${e.condition}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function collectNodeIds(edges: readonly WorkflowGraphEdge[]): Set<string> {
|
||||||
* Extract the linear spine from the graph using topological ordering.
|
|
||||||
* Forward edges go from lower rank to higher rank; feedback edges go backwards.
|
|
||||||
* Self-loops are neither forward nor feedback — they're handled separately.
|
|
||||||
*/
|
|
||||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: topological sort is inherently branchy
|
|
||||||
function extractSpine(edges: readonly WorkflowGraphEdge[]): string[] {
|
|
||||||
// Collect all node IDs
|
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
for (const e of edges) {
|
for (const e of edges) {
|
||||||
ids.add(e.from);
|
ids.add(e.from);
|
||||||
ids.add(e.to);
|
ids.add(e.to);
|
||||||
}
|
}
|
||||||
|
return ids;
|
||||||
// Build adjacency for forward edges only (non-self-loop, non-FALLBACK-back)
|
|
||||||
// Strategy: BFS from __start__, picking the first non-FALLBACK forward edge,
|
|
||||||
// or FALLBACK if no other option.
|
|
||||||
const forwardAdj = new Map<string, string[]>();
|
|
||||||
for (const e of edges) {
|
|
||||||
if (e.from === e.to) continue;
|
|
||||||
const existing = forwardAdj.get(e.from) ?? [];
|
|
||||||
existing.push(e.to);
|
|
||||||
forwardAdj.set(e.from, existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk the main path: prefer non-FALLBACK edges for the spine ordering
|
|
||||||
const visited = new Set<string>();
|
|
||||||
const spine: string[] = [];
|
|
||||||
|
|
||||||
// Build a set of "primary" next targets per node (non-FALLBACK first)
|
|
||||||
const primaryNext = new Map<string, string>();
|
|
||||||
const edgesByFrom = new Map<string, WorkflowGraphEdge[]>();
|
|
||||||
for (const e of edges) {
|
|
||||||
if (e.from === e.to) continue;
|
|
||||||
const list = edgesByFrom.get(e.from) ?? [];
|
|
||||||
list.push(e);
|
|
||||||
edgesByFrom.set(e.from, list);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For each node, the "primary" next is the first non-FALLBACK target,
|
|
||||||
// or the FALLBACK target if all edges are FALLBACK
|
|
||||||
for (const [from, edgeList] of edgesByFrom) {
|
|
||||||
const nonFallback = edgeList.find((e) => e.condition !== "FALLBACK");
|
|
||||||
const fallback = edgeList.find((e) => e.condition === "FALLBACK");
|
|
||||||
primaryNext.set(from, nonFallback?.to ?? fallback?.to ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk the spine from __start__
|
|
||||||
let current: string | null = START_ID;
|
|
||||||
while (current !== null && !visited.has(current)) {
|
|
||||||
visited.add(current);
|
|
||||||
spine.push(current);
|
|
||||||
const next = primaryNext.get(current);
|
|
||||||
if (next !== undefined && next !== "" && !visited.has(next)) {
|
|
||||||
current = next;
|
|
||||||
} else {
|
|
||||||
current = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add any remaining nodes not on the main path (shouldn't normally happen)
|
|
||||||
for (const id of ids) {
|
|
||||||
if (!visited.has(id)) {
|
|
||||||
spine.push(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return spine;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectBackEdges(ids: Set<string>, edges: readonly WorkflowGraphEdge[]): Set<string> {
|
||||||
|
const WHITE = 0;
|
||||||
|
const GRAY = 1;
|
||||||
|
const BLACK = 2;
|
||||||
|
const backEdges = new Set<string>();
|
||||||
|
const color = new Map<string, number>();
|
||||||
|
for (const id of ids) color.set(id, WHITE);
|
||||||
|
|
||||||
|
const fullAdj = new Map<string, string[]>();
|
||||||
|
for (const id of ids) fullAdj.set(id, []);
|
||||||
|
for (const e of edges) {
|
||||||
|
if (e.from !== e.to) fullAdj.get(e.from)?.push(e.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dfs(u: string): void {
|
||||||
|
color.set(u, GRAY);
|
||||||
|
for (const v of fullAdj.get(u) ?? []) {
|
||||||
|
const c = color.get(v) ?? WHITE;
|
||||||
|
if (c === GRAY) {
|
||||||
|
backEdges.add(`${u}->${v}`);
|
||||||
|
} else if (c === WHITE) {
|
||||||
|
dfs(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
color.set(u, BLACK);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.has(START_ID)) dfs(START_ID);
|
||||||
|
for (const id of ids) {
|
||||||
|
if ((color.get(id) ?? WHITE) === WHITE) dfs(id);
|
||||||
|
}
|
||||||
|
return backEdges;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDagAdjacency(
|
||||||
|
ids: Set<string>,
|
||||||
|
edges: readonly WorkflowGraphEdge[],
|
||||||
|
backEdges: Set<string>,
|
||||||
|
): Map<string, string[]> {
|
||||||
|
const adj = new Map<string, string[]>();
|
||||||
|
for (const id of ids) adj.set(id, []);
|
||||||
|
for (const e of edges) {
|
||||||
|
if (e.from === e.to) continue;
|
||||||
|
if (backEdges.has(`${e.from}->${e.to}`)) continue;
|
||||||
|
adj.get(e.from)?.push(e.to);
|
||||||
|
}
|
||||||
|
return adj;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeInDegrees(ids: Set<string>, adj: Map<string, string[]>): Map<string, number> {
|
||||||
|
const inDegree = new Map<string, number>();
|
||||||
|
for (const id of ids) inDegree.set(id, 0);
|
||||||
|
for (const id of ids) {
|
||||||
|
for (const next of adj.get(id) ?? []) {
|
||||||
|
inDegree.set(next, (inDegree.get(next) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inDegree;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relaxLongestPathNeighbors(
|
||||||
|
cur: string,
|
||||||
|
curRank: number,
|
||||||
|
adj: Map<string, string[]>,
|
||||||
|
rank: Map<string, number>,
|
||||||
|
inDegree: Map<string, number>,
|
||||||
|
queue: string[],
|
||||||
|
): void {
|
||||||
|
for (const next of adj.get(cur) ?? []) {
|
||||||
|
const prevRank = rank.get(next) ?? 0;
|
||||||
|
if (curRank + 1 > prevRank) rank.set(next, curRank + 1);
|
||||||
|
const deg = (inDegree.get(next) ?? 1) - 1;
|
||||||
|
inDegree.set(next, deg);
|
||||||
|
if (deg === 0) queue.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function longestPathRanks(ids: Set<string>, adj: Map<string, string[]>): Map<string, number> {
|
||||||
|
const inDegree = computeInDegrees(ids, adj);
|
||||||
|
const rank = new Map<string, number>();
|
||||||
|
const queue: string[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
if ((inDegree.get(id) ?? 0) === 0) {
|
||||||
|
queue.push(id);
|
||||||
|
rank.set(id, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const cur = queue.shift();
|
||||||
|
if (cur === undefined) break;
|
||||||
|
relaxLongestPathNeighbors(cur, rank.get(cur) ?? 0, adj, rank, inDegree, queue);
|
||||||
|
}
|
||||||
|
return rank;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareLayerNodes(a: string, b: string): number {
|
||||||
|
if (a === START_ID) return -1;
|
||||||
|
if (b === START_ID) return 1;
|
||||||
|
if (a === END_ID) return 1;
|
||||||
|
if (b === END_ID) return -1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ranksToLayers(rank: Map<string, number>): string[][] {
|
||||||
|
const maxRank = Math.max(...[...rank.values()], 0);
|
||||||
|
const layers: string[][] = [];
|
||||||
|
for (let r = 0; r <= maxRank; r++) layers.push([]);
|
||||||
|
for (const [id, r] of rank) layers[r].push(id);
|
||||||
|
for (const layer of layers) layer.sort(compareLayerNodes);
|
||||||
|
return layers.filter((l) => l.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Strategy 1: Longest-path layering (Sugiyama step 1) ─────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign layers via longest path from sources.
|
||||||
|
*
|
||||||
|
* For each node, rank = max(rank(pred) + 1) over all predecessors.
|
||||||
|
* This guarantees that if a -> b (and not b -> a), rank(a) < rank(b).
|
||||||
|
*
|
||||||
|
* Back-edges (cycles) are detected and excluded from ranking:
|
||||||
|
* we first remove edges that create cycles (DFS-based), compute ranks
|
||||||
|
* on the resulting DAG, then the removed edges become feedback edges.
|
||||||
|
*/
|
||||||
|
function computeLayersLongestPath(edges: readonly WorkflowGraphEdge[]): string[][] {
|
||||||
|
const ids = collectNodeIds(edges);
|
||||||
|
const backEdges = detectBackEdges(ids, edges);
|
||||||
|
const adj = buildDagAdjacency(ids, edges, backEdges);
|
||||||
|
const rank = longestPathRanks(ids, adj);
|
||||||
|
return ranksToLayers(rank);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildRoleNode(
|
function buildRoleNode(
|
||||||
id: string,
|
id: string,
|
||||||
pos: { x: number; y: number },
|
pos: { x: number; y: number },
|
||||||
@@ -137,92 +211,169 @@ function buildTerminalNode(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeLayout(input: LayoutInput): LayoutResult {
|
type EdgeLayoutContext = {
|
||||||
const spine = extractSpine(input.edges);
|
rank: Map<string, number>;
|
||||||
|
nodePositions: Map<string, { x: number; y: number; w: number; h: number }>;
|
||||||
|
centerX: number;
|
||||||
|
routedCountByTarget: Map<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function computeEdgeLabelPosition(
|
||||||
|
e: WorkflowGraphEdge,
|
||||||
|
ctx: EdgeLayoutContext,
|
||||||
|
isFeedback: boolean,
|
||||||
|
isSkipForward: boolean,
|
||||||
|
isSelfLoop: boolean,
|
||||||
|
): { labelX: number | null; labelY: number | null; feedbackSide: "right" | "left" | null } {
|
||||||
|
const sourcePos = ctx.nodePositions.get(e.from);
|
||||||
|
const targetPos = ctx.nodePositions.get(e.to);
|
||||||
|
if (sourcePos === undefined || targetPos === undefined) {
|
||||||
|
return { labelX: null, labelY: null, feedbackSide: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFeedback || isSkipForward) {
|
||||||
|
const count = ctx.routedCountByTarget.get(e.to) ?? 0;
|
||||||
|
ctx.routedCountByTarget.set(e.to, count + 1);
|
||||||
|
const feedbackSide = count % 2 === 0 ? "right" : "left";
|
||||||
|
const offsetX =
|
||||||
|
feedbackSide === "right"
|
||||||
|
? ctx.centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X
|
||||||
|
: ctx.centerX - ROLE_NODE_WIDTH / 2 - FEEDBACK_OFFSET_X;
|
||||||
|
const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2;
|
||||||
|
return { labelX: offsetX, labelY: midY, feedbackSide };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSelfLoop) {
|
||||||
|
return { labelX: null, labelY: null, feedbackSide: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2;
|
||||||
|
return { labelX: ctx.centerX, labelY: midY, feedbackSide: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConditionEdge(e: WorkflowGraphEdge, ctx: EdgeLayoutContext): Edge {
|
||||||
|
const isFallback = e.condition === "FALLBACK";
|
||||||
|
const isSelfLoop = e.from === e.to;
|
||||||
|
const sourceRank = ctx.rank.get(e.from) ?? 0;
|
||||||
|
const targetRank = ctx.rank.get(e.to) ?? 0;
|
||||||
|
const isFeedback = !isSelfLoop && targetRank <= sourceRank;
|
||||||
|
const isSkipForward = !isSelfLoop && !isFeedback && targetRank - sourceRank > 1;
|
||||||
|
const routed = isFeedback || isSkipForward;
|
||||||
|
|
||||||
|
const { labelX, labelY, feedbackSide } = computeEdgeLabelPosition(
|
||||||
|
e,
|
||||||
|
ctx,
|
||||||
|
isFeedback,
|
||||||
|
isSkipForward,
|
||||||
|
isSelfLoop,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: edgeKey(e),
|
||||||
|
source: e.from,
|
||||||
|
target: e.to,
|
||||||
|
sourceHandle: routed ? (feedbackSide === "left" ? "left-out" : "right-out") : "bottom-out",
|
||||||
|
targetHandle: routed ? (feedbackSide === "left" ? "left-in" : "right-in") : "top-in",
|
||||||
|
type: "condition",
|
||||||
|
data: {
|
||||||
|
condition: e.condition,
|
||||||
|
conditionDescription: e.conditionDescription,
|
||||||
|
isFallback,
|
||||||
|
isFeedback: routed,
|
||||||
|
isSelfLoop,
|
||||||
|
feedbackSide,
|
||||||
|
labelX,
|
||||||
|
labelY,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAYER_H_GAP = 40;
|
||||||
|
|
||||||
|
type NodePosition = { x: number; y: number; w: number; h: number };
|
||||||
|
|
||||||
|
function layerIndexRank(layers: string[][]): Map<string, number> {
|
||||||
const rank = new Map<string, number>();
|
const rank = new Map<string, number>();
|
||||||
for (let i = 0; i < spine.length; i++) {
|
for (let i = 0; i < layers.length; i++) {
|
||||||
rank.set(spine[i], i);
|
for (const id of layers[i]) rank.set(id, i);
|
||||||
}
|
}
|
||||||
|
return rank;
|
||||||
|
}
|
||||||
|
|
||||||
// Position nodes along a vertical spine, centered horizontally
|
function computeLayerWidths(layers: string[][], hGap: number): number[] {
|
||||||
const centerX = ROLE_NODE_WIDTH / 2; // left edge at x=0, center at width/2
|
return layers.map((layer) => {
|
||||||
const nodePositions = new Map<string, { x: number; y: number; w: number; h: number }>();
|
let w = 0;
|
||||||
|
for (const id of layer) w += nodeSize(id).width;
|
||||||
let y = 0;
|
return w + (layer.length - 1) * hGap;
|
||||||
for (const id of spine) {
|
|
||||||
const size = nodeSize(id);
|
|
||||||
// Center-align all nodes on the spine
|
|
||||||
const x = centerX - size.width / 2;
|
|
||||||
nodePositions.set(id, { x, y, w: size.width, h: size.height });
|
|
||||||
y += size.height + LAYER_GAP;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build nodes
|
|
||||||
const nodes: Node[] = [];
|
|
||||||
for (const id of spine) {
|
|
||||||
const pos = nodePositions.get(id);
|
|
||||||
if (pos === undefined) continue;
|
|
||||||
const state = input.nodeStates.get(id) ?? "default";
|
|
||||||
if (id === START_ID || id === END_ID) {
|
|
||||||
nodes.push(buildTerminalNode(id, { x: pos.x, y: pos.y }, state));
|
|
||||||
} else {
|
|
||||||
nodes.push(buildRoleNode(id, { x: pos.x, y: pos.y }, input.roles, state));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build edges with label positions
|
|
||||||
// For feedback edges (target rank < source rank), we'll compute label at midpoint
|
|
||||||
// of the right-side arc. The actual SVG path is drawn by ConditionEdge component.
|
|
||||||
const edges: Edge[] = input.edges.map((e) => {
|
|
||||||
const isFallback = e.condition === "FALLBACK";
|
|
||||||
const isSelfLoop = e.from === e.to;
|
|
||||||
const sourceRank = rank.get(e.from) ?? 0;
|
|
||||||
const targetRank = rank.get(e.to) ?? 0;
|
|
||||||
const isFeedback = !isSelfLoop && targetRank <= sourceRank;
|
|
||||||
|
|
||||||
const sourcePos = nodePositions.get(e.from);
|
|
||||||
const targetPos = nodePositions.get(e.to);
|
|
||||||
|
|
||||||
let labelX: number | null = null;
|
|
||||||
let labelY: number | null = null;
|
|
||||||
|
|
||||||
if (sourcePos !== undefined && targetPos !== undefined) {
|
|
||||||
if (isFeedback) {
|
|
||||||
// Label on the right side of the feedback arc
|
|
||||||
const rightX = centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X;
|
|
||||||
const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2;
|
|
||||||
labelX = rightX;
|
|
||||||
labelY = midY;
|
|
||||||
} else if (!isSelfLoop) {
|
|
||||||
// Forward edge: label between source bottom and target top
|
|
||||||
const midX = centerX;
|
|
||||||
const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2;
|
|
||||||
labelX = midX;
|
|
||||||
labelY = midY;
|
|
||||||
}
|
|
||||||
// Self-loop: let ReactFlow default handle it
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: edgeKey(e),
|
|
||||||
source: e.from,
|
|
||||||
target: e.to,
|
|
||||||
type: "condition",
|
|
||||||
data: {
|
|
||||||
condition: e.condition,
|
|
||||||
conditionDescription: e.conditionDescription,
|
|
||||||
isFallback,
|
|
||||||
isFeedback,
|
|
||||||
isSelfLoop,
|
|
||||||
labelX,
|
|
||||||
labelY,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutNodePositions(
|
||||||
|
layers: string[][],
|
||||||
|
layerWidths: number[],
|
||||||
|
centerX: number,
|
||||||
|
hGap: number,
|
||||||
|
): Map<string, NodePosition> {
|
||||||
|
const nodePositions = new Map<string, NodePosition>();
|
||||||
|
let y = 0;
|
||||||
|
for (let li = 0; li < layers.length; li++) {
|
||||||
|
const layer = layers[li];
|
||||||
|
let x = centerX - layerWidths[li] / 2;
|
||||||
|
let maxH = 0;
|
||||||
|
for (const id of layer) {
|
||||||
|
const size = nodeSize(id);
|
||||||
|
nodePositions.set(id, { x, y, w: size.width, h: size.height });
|
||||||
|
x += size.width + hGap;
|
||||||
|
if (size.height > maxH) maxH = size.height;
|
||||||
|
}
|
||||||
|
y += maxH + LAYER_GAP;
|
||||||
|
}
|
||||||
|
return nodePositions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLayoutNodes(
|
||||||
|
layers: string[][],
|
||||||
|
nodePositions: Map<string, NodePosition>,
|
||||||
|
input: LayoutInput,
|
||||||
|
): Node[] {
|
||||||
|
const nodes: Node[] = [];
|
||||||
|
for (const layer of layers) {
|
||||||
|
for (const id of layer) {
|
||||||
|
const pos = nodePositions.get(id);
|
||||||
|
if (pos === undefined) continue;
|
||||||
|
const state = input.nodeStates.get(id) ?? "default";
|
||||||
|
const xy = { x: pos.x, y: pos.y };
|
||||||
|
if (id === START_ID || id === END_ID) {
|
||||||
|
nodes.push(buildTerminalNode(id, xy, state));
|
||||||
|
} else {
|
||||||
|
nodes.push(buildRoleNode(id, xy, input.roles, state));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Longest-path layout (uses same edge-building as before) ─────────
|
||||||
|
|
||||||
|
function computeLayoutLongestPath(input: LayoutInput): LayoutResult {
|
||||||
|
const layers = computeLayersLongestPath(input.edges);
|
||||||
|
const rank = layerIndexRank(layers);
|
||||||
|
const layerWidths = computeLayerWidths(layers, LAYER_H_GAP);
|
||||||
|
const centerX = Math.max(...layerWidths, ROLE_NODE_WIDTH) / 2;
|
||||||
|
const nodePositions = layoutNodePositions(layers, layerWidths, centerX, LAYER_H_GAP);
|
||||||
|
const nodes = buildLayoutNodes(layers, nodePositions, input);
|
||||||
|
const edgeCtx: EdgeLayoutContext = {
|
||||||
|
rank,
|
||||||
|
nodePositions,
|
||||||
|
centerX,
|
||||||
|
routedCountByTarget: new Map<string, number>(),
|
||||||
|
};
|
||||||
|
const edges: Edge[] = input.edges.map((e) => buildConditionEdge(e, edgeCtx));
|
||||||
return { nodes, edges };
|
return { nodes, edges };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Public hook ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useLayout(input: LayoutInput): LayoutResult {
|
export function useLayout(input: LayoutInput): LayoutResult {
|
||||||
return useMemo(() => computeLayout(input), [input]);
|
return useMemo(() => computeLayoutLongestPath(input), [input]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,16 +32,16 @@ const edgeTypes: EdgeTypes = {
|
|||||||
condition: ConditionEdge,
|
condition: ConditionEdge,
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleRoleNodeClick(onRoleClick: (roleName: string) => void, node: Node): void {
|
function handleNodeClick(onNodeClick: (nodeId: string) => void, node: Node): void {
|
||||||
if (node.type !== "role") return;
|
if (node.type !== "role" && node.type !== "terminal") return;
|
||||||
onRoleClick(node.id);
|
onNodeClick(node.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) {
|
export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) {
|
||||||
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
|
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
|
||||||
|
|
||||||
const onNodeClickHandler: OnNodeClick | undefined =
|
const onNodeClickHandler: OnNodeClick | undefined =
|
||||||
onNodeClick !== null ? (_e, node) => handleRoleNodeClick(onNodeClick, node) : undefined;
|
onNodeClick !== null ? (_e, node) => handleNodeClick(onNodeClick, node) : undefined;
|
||||||
|
|
||||||
const styledEdges = useMemo(
|
const styledEdges = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -1,175 +1,13 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { listWorkflows } from "../api.ts";
|
||||||
import type { WorkflowDetail } from "../api.ts";
|
|
||||||
import { getWorkflowDetail, listWorkflows } from "../api.ts";
|
|
||||||
import { useFetch } from "../hooks.ts";
|
import { useFetch } from "../hooks.ts";
|
||||||
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
agent: string;
|
client: string;
|
||||||
|
onSelect: (name: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DetailCacheEntry =
|
export function WorkflowList({ client, onSelect }: Props) {
|
||||||
| { status: "loading" }
|
const { status, data, error } = useFetch(() => listWorkflows(client), [client]);
|
||||||
| { status: "error"; message: string }
|
|
||||||
| { status: "ok"; detail: WorkflowDetail };
|
|
||||||
|
|
||||||
function versionCount(detail: WorkflowDetail): number {
|
|
||||||
return detail.history.length + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExpandedWorkflowBody({
|
|
||||||
cacheEntry,
|
|
||||||
staticNodeStates,
|
|
||||||
}: {
|
|
||||||
cacheEntry: DetailCacheEntry | undefined;
|
|
||||||
staticNodeStates: Map<string, NodeState>;
|
|
||||||
}) {
|
|
||||||
if (cacheEntry === undefined || cacheEntry.status === "loading") {
|
|
||||||
return (
|
|
||||||
<p className="text-sm py-2" style={{ color: "var(--color-text-muted)" }}>
|
|
||||||
Loading workflow details...
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cacheEntry.status === "error") {
|
|
||||||
return (
|
|
||||||
<p className="text-sm py-2" style={{ color: "var(--color-error)" }}>
|
|
||||||
{cacheEntry.message}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { detail } = cacheEntry;
|
|
||||||
const descriptor = detail.descriptor;
|
|
||||||
const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
|
|
||||||
const vc = versionCount(detail);
|
|
||||||
|
|
||||||
const hasGraph = descriptor !== null && edgeCount > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pt-3 border-t flex gap-4" style={{ borderColor: "var(--color-border)" }}>
|
|
||||||
<div className="space-y-3 shrink-0" style={{ minWidth: 200, maxWidth: 280 }}>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
|
||||||
{detail.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs mt-1 mb-1" style={{ color: "var(--color-text-muted)" }}>
|
|
||||||
Hash
|
|
||||||
</p>
|
|
||||||
<code className="text-xs font-mono block" style={{ color: "var(--color-accent)" }}>
|
|
||||||
{detail.hash}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
|
||||||
{vc} version{vc !== 1 ? "s" : ""}
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs mb-1 font-medium" style={{ color: "var(--color-text-muted)" }}>
|
|
||||||
Description
|
|
||||||
</p>
|
|
||||||
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--color-text)" }}>
|
|
||||||
{descriptor !== null && descriptor.description !== ""
|
|
||||||
? descriptor.description
|
|
||||||
: descriptor !== null
|
|
||||||
? "—"
|
|
||||||
: "No descriptor available for this workflow version."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{hasGraph ? (
|
|
||||||
<div
|
|
||||||
className="rounded-lg border overflow-hidden flex-1"
|
|
||||||
style={{
|
|
||||||
borderColor: "var(--color-border)",
|
|
||||||
background: "var(--color-bg)",
|
|
||||||
minHeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="px-3 py-2 text-xs flex justify-between items-center"
|
|
||||||
style={{ color: "var(--color-text-muted)", background: "var(--color-surface)" }}
|
|
||||||
>
|
|
||||||
<span className="font-mono">Workflow graph</span>
|
|
||||||
<span>
|
|
||||||
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ height: 600, width: "100%" }}>
|
|
||||||
<WorkflowGraph
|
|
||||||
graph={descriptor.graph}
|
|
||||||
roles={descriptor.roles}
|
|
||||||
nodeStates={staticNodeStates}
|
|
||||||
onNodeClick={null}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkflowList({ agent }: Props) {
|
|
||||||
const { status, data, error } = useFetch(() => listWorkflows(agent), [agent]);
|
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
|
||||||
const [detailsByName, setDetailsByName] = useState<Map<string, DetailCacheEntry>>(
|
|
||||||
() => new Map(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const staticNodeStates = useMemo(() => new Map<string, NodeState>(), []);
|
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset expansion when switching agents
|
|
||||||
useEffect(() => {
|
|
||||||
setExpanded(new Set());
|
|
||||||
setDetailsByName(new Map());
|
|
||||||
}, [agent]);
|
|
||||||
|
|
||||||
const ensureDetailLoaded = useCallback(
|
|
||||||
(name: string) => {
|
|
||||||
setDetailsByName((prev) => {
|
|
||||||
const cur = prev.get(name);
|
|
||||||
if (cur !== undefined && (cur.status === "ok" || cur.status === "loading")) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return new Map(prev).set(name, { status: "loading" });
|
|
||||||
});
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const detail = await getWorkflowDetail(agent, name);
|
|
||||||
setDetailsByName((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(name, { status: "ok", detail });
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
|
||||||
setDetailsByName((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(name, { status: "error", message });
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
[agent],
|
|
||||||
);
|
|
||||||
|
|
||||||
function toggleExpanded(name: string) {
|
|
||||||
const wasExpanded = expanded.has(name);
|
|
||||||
setExpanded((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(name)) {
|
|
||||||
next.delete(name);
|
|
||||||
} else {
|
|
||||||
next.add(name);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
if (!wasExpanded) {
|
|
||||||
ensureDetailLoaded(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "loading")
|
if (status === "loading")
|
||||||
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
|
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
|
||||||
@@ -184,58 +22,34 @@ export function WorkflowList({ agent }: Props) {
|
|||||||
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p>
|
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{workflows.map((w) => {
|
{workflows.map((w) => (
|
||||||
const isOpen = expanded.has(w.name);
|
<button
|
||||||
return (
|
key={w.name}
|
||||||
<div
|
type="button"
|
||||||
key={w.name}
|
onClick={() => onSelect(w.name)}
|
||||||
className="rounded-lg border overflow-hidden"
|
className="w-full text-left p-4 rounded-lg border hover:opacity-90"
|
||||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
style={{
|
||||||
>
|
background: "var(--color-surface)",
|
||||||
<button
|
borderColor: "var(--color-border)",
|
||||||
type="button"
|
color: "var(--color-text)",
|
||||||
onClick={() => toggleExpanded(w.name)}
|
}}
|
||||||
className="w-full text-left p-4 flex items-start justify-between gap-3 hover:opacity-90"
|
>
|
||||||
style={{ color: "var(--color-text)" }}
|
<div className="flex items-center gap-2">
|
||||||
aria-expanded={isOpen}
|
<span className="font-medium">{w.name}</span>
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="text-xs font-mono"
|
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
{isOpen ? "▼" : "▶"}
|
|
||||||
</span>
|
|
||||||
<span className="font-medium">{w.name}</span>
|
|
||||||
</div>
|
|
||||||
<code
|
|
||||||
className="text-xs mt-1 block font-mono truncate"
|
|
||||||
style={{ color: "var(--color-accent)" }}
|
|
||||||
>
|
|
||||||
{w.hash !== null ? w.hash : "—"}
|
|
||||||
</code>
|
|
||||||
{w.timestamp !== null ? (
|
|
||||||
<span
|
|
||||||
className="text-xs mt-1 block"
|
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
Updated {new Date(w.timestamp).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{isOpen ? (
|
|
||||||
<div className="px-4 pb-4">
|
|
||||||
<ExpandedWorkflowBody
|
|
||||||
cacheEntry={detailsByName.get(w.name)}
|
|
||||||
staticNodeStates={staticNodeStates}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<code
|
||||||
})}
|
className="text-xs mt-1 block font-mono truncate"
|
||||||
|
style={{ color: "var(--color-accent)" }}
|
||||||
|
>
|
||||||
|
{w.hash !== null ? w.hash : "—"}
|
||||||
|
</code>
|
||||||
|
{w.timestamp !== null ? (
|
||||||
|
<span className="text-xs mt-1 block" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
Updated {new Date(w.timestamp).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,36 +4,42 @@ type View = "threads" | "workflows";
|
|||||||
|
|
||||||
type HashRoute = {
|
type HashRoute = {
|
||||||
view: View;
|
view: View;
|
||||||
agent: string | null;
|
client: string | null;
|
||||||
threadId: string | null;
|
threadId: string | null;
|
||||||
|
workflowName: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseHash(hash: string): HashRoute {
|
function parseHash(hash: string): HashRoute {
|
||||||
const raw = hash.replace(/^#\/?/, "");
|
const raw = hash.replace(/^#\/?/, "");
|
||||||
// Format: #agent/threads/id or #agent/workflows or #threads or #workflows
|
// Format: #client/threads/id or #client/workflows or #threads or #workflows
|
||||||
const parts = raw.split("/");
|
const parts = raw.split("/");
|
||||||
|
|
||||||
// Check if first part is a known view
|
// Check if first part is a known view
|
||||||
if (parts[0] === "threads" || parts[0] === "workflows") {
|
if (parts[0] === "threads" || parts[0] === "workflows") {
|
||||||
return {
|
return {
|
||||||
view: parts[0] as View,
|
view: parts[0] as View,
|
||||||
agent: null,
|
client: null,
|
||||||
threadId: parts[0] === "threads" && parts.length > 1 ? parts.slice(1).join("/") : null,
|
threadId: parts[0] === "threads" && parts.length > 1 ? parts.slice(1).join("/") : null,
|
||||||
|
workflowName: parts[0] === "workflows" && parts.length > 1 ? parts.slice(1).join("/") : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// First part is agent name
|
// First part is client name
|
||||||
const agent = parts[0] || null;
|
const client = parts[0] || null;
|
||||||
const viewPart = parts[1] ?? "threads";
|
const viewPart = parts[1] ?? "threads";
|
||||||
const view: View = viewPart === "workflows" ? "workflows" : "threads";
|
const view: View = viewPart === "workflows" ? "workflows" : "threads";
|
||||||
const threadId = view === "threads" && parts.length > 2 ? parts.slice(2).join("/") : null;
|
const threadId = view === "threads" && parts.length > 2 ? parts.slice(2).join("/") : null;
|
||||||
|
const workflowName = view === "workflows" && parts.length > 2 ? parts.slice(2).join("/") : null;
|
||||||
|
|
||||||
return { view, agent, threadId };
|
return { view, client, threadId, workflowName };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildHash(route: HashRoute): string {
|
function buildHash(route: HashRoute): string {
|
||||||
const prefix = route.agent ? `${route.agent}/` : "";
|
const prefix = route.client ? `${route.client}/` : "";
|
||||||
if (route.view === "workflows") {
|
if (route.view === "workflows") {
|
||||||
|
if (route.workflowName !== null) {
|
||||||
|
return `#${prefix}workflows/${route.workflowName}`;
|
||||||
|
}
|
||||||
return `#${prefix}workflows`;
|
return `#${prefix}workflows`;
|
||||||
}
|
}
|
||||||
if (route.threadId !== null) {
|
if (route.threadId !== null) {
|
||||||
@@ -44,11 +50,13 @@ function buildHash(route: HashRoute): string {
|
|||||||
|
|
||||||
export function useHashRoute(): {
|
export function useHashRoute(): {
|
||||||
view: View;
|
view: View;
|
||||||
agent: string | null;
|
client: string | null;
|
||||||
threadId: string | null;
|
threadId: string | null;
|
||||||
|
workflowName: string | null;
|
||||||
setView: (v: View) => void;
|
setView: (v: View) => void;
|
||||||
setAgent: (a: string | null) => void;
|
setClient: (a: string | null) => void;
|
||||||
setThreadId: (id: string | null) => void;
|
setThreadId: (id: string | null) => void;
|
||||||
|
setWorkflowName: (name: string | null) => void;
|
||||||
} {
|
} {
|
||||||
const [route, setRoute] = useState<HashRoute>(() => parseHash(window.location.hash));
|
const [route, setRoute] = useState<HashRoute>(() => parseHash(window.location.hash));
|
||||||
|
|
||||||
@@ -67,26 +75,36 @@ export function useHashRoute(): {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setView = useCallback(
|
const setView = useCallback(
|
||||||
(v: View) => navigate({ view: v, agent: route.agent, threadId: null }),
|
(v: View) => navigate({ view: v, client: route.client, threadId: null, workflowName: null }),
|
||||||
[navigate, route.agent],
|
[navigate, route.client],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setAgent = useCallback(
|
const setClient = useCallback(
|
||||||
(a: string | null) => navigate({ view: route.view, agent: a, threadId: null }),
|
(a: string | null) =>
|
||||||
|
navigate({ view: route.view, client: a, threadId: null, workflowName: null }),
|
||||||
[navigate, route.view],
|
[navigate, route.view],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setThreadId = useCallback(
|
const setThreadId = useCallback(
|
||||||
(id: string | null) => navigate({ view: "threads", agent: route.agent, threadId: id }),
|
(id: string | null) =>
|
||||||
[navigate, route.agent],
|
navigate({ view: "threads", client: route.client, threadId: id, workflowName: null }),
|
||||||
|
[navigate, route.client],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setWorkflowName = useCallback(
|
||||||
|
(name: string | null) =>
|
||||||
|
navigate({ view: "workflows", client: route.client, threadId: null, workflowName: name }),
|
||||||
|
[navigate, route.client],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
view: route.view,
|
view: route.view,
|
||||||
agent: route.agent,
|
client: route.client,
|
||||||
threadId: route.threadId,
|
threadId: route.threadId,
|
||||||
|
workflowName: route.workflowName,
|
||||||
setView,
|
setView,
|
||||||
setAgent,
|
setClient,
|
||||||
setThreadId,
|
setThreadId,
|
||||||
|
setWorkflowName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,17 +57,17 @@ function handleRecordEvent(ev: Event, ctx: RecordEventContext): void {
|
|||||||
ctx.cleanupEs();
|
ctx.cleanupEs();
|
||||||
}
|
}
|
||||||
|
|
||||||
function sseUrl(agent: string, threadId: string): string {
|
function sseUrl(client: string, threadId: string): string {
|
||||||
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
|
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
|
||||||
const key = getApiKey();
|
const key = getApiKey();
|
||||||
const keyParam = key ? `?key=${encodeURIComponent(key)}` : "";
|
const keyParam = key ? `?key=${encodeURIComponent(key)}` : "";
|
||||||
if (gatewayUrl) {
|
if (gatewayUrl) {
|
||||||
return `${gatewayUrl}/api/${agent}/threads/${encodeURIComponent(threadId)}/live${keyParam}`;
|
return `${gatewayUrl}/api/${client}/threads/${encodeURIComponent(threadId)}/live${keyParam}`;
|
||||||
}
|
}
|
||||||
return `/api/threads/${encodeURIComponent(threadId)}/live`;
|
return `/api/threads/${encodeURIComponent(threadId)}/live`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSSE(agent: string | null, threadId: string | null): UseSSEReturn {
|
export function useSSE(client: string | null, threadId: string | null): UseSSEReturn {
|
||||||
const [records, setRecords] = useState<ThreadRecord[]>([]);
|
const [records, setRecords] = useState<ThreadRecord[]>([]);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [completed, setCompleted] = useState(false);
|
const [completed, setCompleted] = useState(false);
|
||||||
@@ -76,7 +76,7 @@ export function useSSE(agent: string | null, threadId: string | null): UseSSERet
|
|||||||
const reconnectAttemptsRef = useRef(0);
|
const reconnectAttemptsRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (threadId === null || agent === null) {
|
if (threadId === null || client === null) {
|
||||||
completedRef.current = false;
|
completedRef.current = false;
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
setRecords([]);
|
setRecords([]);
|
||||||
@@ -86,7 +86,7 @@ export function useSSE(agent: string | null, threadId: string | null): UseSSERet
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tid = threadId;
|
const tid = threadId;
|
||||||
const agentName = agent;
|
const clientName = client;
|
||||||
|
|
||||||
completedRef.current = false;
|
completedRef.current = false;
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
@@ -125,7 +125,7 @@ export function useSSE(agent: string | null, threadId: string | null): UseSSERet
|
|||||||
}
|
}
|
||||||
|
|
||||||
cleanupEs();
|
cleanupEs();
|
||||||
const url = sseUrl(agentName, tid);
|
const url = sseUrl(clientName, tid);
|
||||||
es = new EventSource(url);
|
es = new EventSource(url);
|
||||||
|
|
||||||
es.onopen = () => {
|
es.onopen = () => {
|
||||||
@@ -177,7 +177,7 @@ export function useSSE(agent: string | null, threadId: string | null): UseSSERet
|
|||||||
}
|
}
|
||||||
cleanupEs();
|
cleanupEs();
|
||||||
};
|
};
|
||||||
}, [agent, threadId]);
|
}, [client, threadId]);
|
||||||
|
|
||||||
return { records, connected, completed };
|
return { records, connected, completed };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,66 @@
|
|||||||
# @uncaged/workflow-execute
|
# @uncaged/workflow-execute
|
||||||
|
|
||||||
|
## 0.5.0-alpha.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- Updated dependencies [f74b482]
|
||||||
|
- Updated dependencies [f74b482]
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-reactor@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-register@0.5.0-alpha.4
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||||
|
|
||||||
|
## 0.5.0-alpha.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-reactor@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-register@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.3
|
||||||
|
|
||||||
|
## 0.5.0-alpha.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-reactor@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-register@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.2
|
||||||
|
|
||||||
|
## 0.5.0-alpha.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-reactor@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-register@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.1
|
||||||
|
|
||||||
|
## 0.5.0-alpha.0
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-cas@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-reactor@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-register@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||||
|
- @uncaged/workflow-util@0.5.0-alpha.0
|
||||||
|
|
||||||
## 0.4.5
|
## 0.4.5
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -0,0 +1,868 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { createMemoryStore, type Store, walk } from "@uncaged/json-cas";
|
||||||
|
import {
|
||||||
|
type ContentPayload,
|
||||||
|
registerWorkflowSchemas,
|
||||||
|
type ThreadEndPayload,
|
||||||
|
type ThreadStartPayload,
|
||||||
|
type ThreadStepPayload,
|
||||||
|
type WorkflowSchemaHashes,
|
||||||
|
} from "@uncaged/json-cas-workflow";
|
||||||
|
import { registerWorkflow, type WorkflowInput } from "@uncaged/workflow-json-def";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildJsonCasThreadContext,
|
||||||
|
buildJsonCasThreadSnapshot,
|
||||||
|
readContentText,
|
||||||
|
} from "../src/engine/json-cas-context.js";
|
||||||
|
import { executeJsonCasThread } from "../src/engine/json-cas-engine.js";
|
||||||
|
import type {
|
||||||
|
JsonCasAgentFn,
|
||||||
|
JsonCasEngineIo,
|
||||||
|
JsonCasEngineOptions,
|
||||||
|
} from "../src/engine/json-cas-types.js";
|
||||||
|
|
||||||
|
// ── Test fixtures ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const START = "__start__";
|
||||||
|
const END = "__end__";
|
||||||
|
|
||||||
|
const SIMPLE_WORKFLOW: WorkflowInput = {
|
||||||
|
name: "test-simple",
|
||||||
|
description: "A simple two-role workflow for testing",
|
||||||
|
roles: {
|
||||||
|
planner: {
|
||||||
|
description: "Plans the work",
|
||||||
|
systemPrompt: "You are a planner.",
|
||||||
|
extractPrompt: "Extract planner output.",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["plan"],
|
||||||
|
properties: { plan: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
coder: {
|
||||||
|
description: "Implements the plan",
|
||||||
|
systemPrompt: "You are a coder.",
|
||||||
|
extractPrompt: "Extract coder output.",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["code"],
|
||||||
|
properties: { code: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
moderator: [
|
||||||
|
{ from: START, to: "planner", when: null },
|
||||||
|
{ from: "planner", to: "coder", when: null },
|
||||||
|
{ from: "coder", to: END, when: null },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SINGLE_ROLE_WORKFLOW: WorkflowInput = {
|
||||||
|
name: "test-single",
|
||||||
|
description: "A single-role workflow",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Does all the work",
|
||||||
|
systemPrompt: "You are a worker.",
|
||||||
|
extractPrompt: "Extract worker output.",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["result"],
|
||||||
|
properties: { result: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
moderator: [
|
||||||
|
{ from: START, to: "worker", when: null },
|
||||||
|
{ from: "worker", to: END, when: null },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONDITIONAL_WORKFLOW: WorkflowInput = {
|
||||||
|
name: "test-conditional",
|
||||||
|
description: "A workflow with JSONata conditions",
|
||||||
|
roles: {
|
||||||
|
checker: {
|
||||||
|
description: "Checks the input",
|
||||||
|
systemPrompt: "You are a checker.",
|
||||||
|
extractPrompt: "Extract checker output.",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["status"],
|
||||||
|
properties: { status: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fixer: {
|
||||||
|
description: "Fixes issues",
|
||||||
|
systemPrompt: "You are a fixer.",
|
||||||
|
extractPrompt: "Extract fixer output.",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["fix"],
|
||||||
|
properties: { fix: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
moderator: [
|
||||||
|
{ from: START, to: "checker", when: null },
|
||||||
|
{ from: "checker", to: END, when: "steps[-1].meta.status = 'ok'" },
|
||||||
|
{ from: "checker", to: "fixer", when: null },
|
||||||
|
{ from: "fixer", to: "checker", when: null },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function noLogger(): (tag: string, content: string) => void {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupStore(): Promise<{
|
||||||
|
store: Store;
|
||||||
|
typeHashes: WorkflowSchemaHashes;
|
||||||
|
}> {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const typeHashes = await registerWorkflowSchemas(store);
|
||||||
|
return { store, typeHashes };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupWorkflow(
|
||||||
|
store: Store,
|
||||||
|
typeHashes: WorkflowSchemaHashes,
|
||||||
|
workflowDef: WorkflowInput,
|
||||||
|
) {
|
||||||
|
const workflowHash = await registerWorkflow(store, typeHashes, workflowDef);
|
||||||
|
return { workflowHash };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeOptions(overrides: Partial<JsonCasEngineOptions> = {}): JsonCasEngineOptions {
|
||||||
|
return {
|
||||||
|
depth: 0,
|
||||||
|
parentThread: null,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
agents: {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeIo(store: Store, typeHashes: WorkflowSchemaHashes, threadId: string): JsonCasEngineIo {
|
||||||
|
return { threadId, store, typeHashes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mock agent that returns a canned text and meta for each role.
|
||||||
|
*/
|
||||||
|
function createMockAgent(
|
||||||
|
responses: Record<string, { text: string; meta: Record<string, unknown> }>,
|
||||||
|
): JsonCasAgentFn {
|
||||||
|
return async (role, _systemPrompt, _snapshot) => {
|
||||||
|
const resp = responses[role];
|
||||||
|
if (resp === undefined) {
|
||||||
|
throw new Error(`mock agent: no response configured for role "${role}"`);
|
||||||
|
}
|
||||||
|
return { ...resp, react: null };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("executeJsonCasThread", () => {
|
||||||
|
describe("thread lifecycle", () => {
|
||||||
|
test("simple two-role workflow creates start, two steps, and end nodes", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||||
|
|
||||||
|
const agentFn = createMockAgent({
|
||||||
|
planner: { text: "I will plan", meta: { plan: "phase-1" } },
|
||||||
|
coder: { text: "I wrote code", meta: { code: "done" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeJsonCasThread({
|
||||||
|
workflowHash,
|
||||||
|
input: "Build a widget",
|
||||||
|
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||||
|
io: makeIo(store, typeHashes, "THREAD01"),
|
||||||
|
options: makeOptions(),
|
||||||
|
agentFn,
|
||||||
|
logger: noLogger(),
|
||||||
|
workflow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.returnCode).toBe(0);
|
||||||
|
expect(result.summary).toContain("END");
|
||||||
|
expect(result.rootHash).toBeTruthy();
|
||||||
|
|
||||||
|
const endNode = store.get(result.rootHash);
|
||||||
|
expect(endNode).not.toBeNull();
|
||||||
|
const endPayload = endNode!.payload as ThreadEndPayload;
|
||||||
|
expect(endPayload.returnCode).toBe(0);
|
||||||
|
expect(endPayload.start).toBeTruthy();
|
||||||
|
expect(endPayload.lastStep).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("single-role workflow creates correct chain", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||||
|
|
||||||
|
const agentFn = createMockAgent({
|
||||||
|
worker: { text: "work done", meta: { result: "success" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeJsonCasThread({
|
||||||
|
workflowHash,
|
||||||
|
input: "Do the thing",
|
||||||
|
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
|
||||||
|
io: makeIo(store, typeHashes, "THREAD02"),
|
||||||
|
options: makeOptions(),
|
||||||
|
agentFn,
|
||||||
|
logger: noLogger(),
|
||||||
|
workflow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.returnCode).toBe(0);
|
||||||
|
|
||||||
|
const endNode = store.get(result.rootHash);
|
||||||
|
expect(endNode).not.toBeNull();
|
||||||
|
const endPayload = endNode!.payload as ThreadEndPayload;
|
||||||
|
|
||||||
|
const lastStepNode = store.get(endPayload.lastStep);
|
||||||
|
expect(lastStepNode).not.toBeNull();
|
||||||
|
const lastStepPayload = lastStepNode!.payload as ThreadStepPayload;
|
||||||
|
expect(lastStepPayload.role).toBe("worker");
|
||||||
|
expect(lastStepPayload.previous).toBeNull();
|
||||||
|
|
||||||
|
const startNode = store.get(endPayload.start);
|
||||||
|
expect(startNode).not.toBeNull();
|
||||||
|
const startPayload = startNode!.payload as ThreadStartPayload;
|
||||||
|
expect(startPayload.input).toBe("Do the thing");
|
||||||
|
expect(startPayload.depth).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CAS node structure", () => {
|
||||||
|
test("thread-start contains workflow ref, input, depth, agents", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||||
|
|
||||||
|
const agentFn = createMockAgent({
|
||||||
|
worker: { text: "ok", meta: { result: "ok" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentHash = await store.put(typeHashes.agent, {
|
||||||
|
package: "test-agent",
|
||||||
|
version: "1.0.0",
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeJsonCasThread({
|
||||||
|
workflowHash,
|
||||||
|
input: "Test input",
|
||||||
|
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
|
||||||
|
io: makeIo(store, typeHashes, "THREAD03"),
|
||||||
|
options: makeOptions({ agents: { worker: agentHash }, depth: 2 }),
|
||||||
|
agentFn,
|
||||||
|
logger: noLogger(),
|
||||||
|
workflow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||||
|
const startPayload = store.get(endPayload.start)!.payload as ThreadStartPayload;
|
||||||
|
|
||||||
|
expect(startPayload.workflow).toBe(workflowHash);
|
||||||
|
expect(startPayload.input).toBe("Test input");
|
||||||
|
expect(startPayload.depth).toBe(2);
|
||||||
|
expect(startPayload.parentThread).toBeNull();
|
||||||
|
expect(startPayload.agents).toEqual({ worker: agentHash });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("thread-start records parentThread when provided", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||||
|
|
||||||
|
const agentFn = createMockAgent({
|
||||||
|
worker: { text: "nested", meta: { result: "nested" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const fakeParent = "FAKEPARENT0001";
|
||||||
|
|
||||||
|
const result = await executeJsonCasThread({
|
||||||
|
workflowHash,
|
||||||
|
input: "nested task",
|
||||||
|
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
|
||||||
|
io: makeIo(store, typeHashes, "THREAD04"),
|
||||||
|
options: makeOptions({ parentThread: fakeParent, depth: 1 }),
|
||||||
|
agentFn,
|
||||||
|
logger: noLogger(),
|
||||||
|
workflow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||||
|
const startPayload = store.get(endPayload.start)!.payload as ThreadStartPayload;
|
||||||
|
expect(startPayload.parentThread).toBe(fakeParent);
|
||||||
|
expect(startPayload.depth).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("each thread-step has content, react, start, and previous refs", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||||
|
|
||||||
|
const agentFn = createMockAgent({
|
||||||
|
planner: { text: "plan text", meta: { plan: "p1" } },
|
||||||
|
coder: { text: "code text", meta: { code: "c1" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeJsonCasThread({
|
||||||
|
workflowHash,
|
||||||
|
input: "go",
|
||||||
|
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||||
|
io: makeIo(store, typeHashes, "THREAD05"),
|
||||||
|
options: makeOptions(),
|
||||||
|
agentFn,
|
||||||
|
logger: noLogger(),
|
||||||
|
workflow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||||
|
const startHash = endPayload.start;
|
||||||
|
|
||||||
|
const step2 = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
|
||||||
|
expect(step2.role).toBe("coder");
|
||||||
|
expect(step2.start).toBe(startHash);
|
||||||
|
expect(step2.previous).not.toBeNull();
|
||||||
|
|
||||||
|
const contentNode2 = store.get(step2.content);
|
||||||
|
expect(contentNode2).not.toBeNull();
|
||||||
|
expect((contentNode2!.payload as ContentPayload).text).toBe("code text");
|
||||||
|
|
||||||
|
const reactNode2 = store.get(step2.react);
|
||||||
|
expect(reactNode2).not.toBeNull();
|
||||||
|
|
||||||
|
const step1 = store.get(step2.previous!)!.payload as ThreadStepPayload;
|
||||||
|
expect(step1.role).toBe("planner");
|
||||||
|
expect(step1.start).toBe(startHash);
|
||||||
|
expect(step1.previous).toBeNull();
|
||||||
|
|
||||||
|
const contentNode1 = store.get(step1.content);
|
||||||
|
expect(contentNode1).not.toBeNull();
|
||||||
|
expect((contentNode1!.payload as ContentPayload).text).toBe("plan text");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("thread-end references start and last step", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||||
|
|
||||||
|
const agentFn = createMockAgent({
|
||||||
|
planner: { text: "plan", meta: { plan: "x" } },
|
||||||
|
coder: { text: "code", meta: { code: "x" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeJsonCasThread({
|
||||||
|
workflowHash,
|
||||||
|
input: "test",
|
||||||
|
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||||
|
io: makeIo(store, typeHashes, "THREAD06"),
|
||||||
|
options: makeOptions(),
|
||||||
|
agentFn,
|
||||||
|
logger: noLogger(),
|
||||||
|
workflow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||||
|
expect(endPayload.returnCode).toBe(0);
|
||||||
|
expect(endPayload.summary).toBeTruthy();
|
||||||
|
|
||||||
|
const startNode = store.get(endPayload.start);
|
||||||
|
expect(startNode).not.toBeNull();
|
||||||
|
expect((startNode!.payload as ThreadStartPayload).workflow).toBe(workflowHash);
|
||||||
|
|
||||||
|
const lastStepNode = store.get(endPayload.lastStep);
|
||||||
|
expect(lastStepNode).not.toBeNull();
|
||||||
|
expect((lastStepNode!.payload as ThreadStepPayload).role).toBe("coder");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("content nodes store the agent text verbatim", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||||
|
|
||||||
|
const longText = "This is a longer text with\nnewlines\nand special chars: <>&\"'";
|
||||||
|
|
||||||
|
const agentFn = createMockAgent({
|
||||||
|
worker: { text: longText, meta: { result: "done" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeJsonCasThread({
|
||||||
|
workflowHash,
|
||||||
|
input: "process this",
|
||||||
|
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
|
||||||
|
io: makeIo(store, typeHashes, "THREAD07"),
|
||||||
|
options: makeOptions(),
|
||||||
|
agentFn,
|
||||||
|
logger: noLogger(),
|
||||||
|
workflow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||||
|
const stepPayload = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
|
||||||
|
const contentPayload = store.get(stepPayload.content)!.payload as ContentPayload;
|
||||||
|
expect(contentPayload.text).toBe(longText);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("meta is stored in thread-step payload", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||||
|
|
||||||
|
const complexMeta = {
|
||||||
|
plan: "phase-1",
|
||||||
|
phases: [{ hash: "abc", title: "first" }],
|
||||||
|
nested: { deep: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const agentFn = createMockAgent({
|
||||||
|
planner: { text: "plan", meta: complexMeta },
|
||||||
|
coder: { text: "code", meta: { code: "done" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeJsonCasThread({
|
||||||
|
workflowHash,
|
||||||
|
input: "go",
|
||||||
|
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||||
|
io: makeIo(store, typeHashes, "THREAD08"),
|
||||||
|
options: makeOptions(),
|
||||||
|
agentFn,
|
||||||
|
logger: noLogger(),
|
||||||
|
workflow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||||
|
const step2 = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
|
||||||
|
const step1 = store.get(step2.previous!)!.payload as ThreadStepPayload;
|
||||||
|
|
||||||
|
expect(step1.meta).toEqual(complexMeta);
|
||||||
|
expect(step2.meta).toEqual({ code: "done" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("moderator routing", () => {
|
||||||
|
test("conditional moderator routes based on agent meta", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const { workflowHash } = await setupWorkflow(store, typeHashes, CONDITIONAL_WORKFLOW);
|
||||||
|
|
||||||
|
let checkerCallCount = 0;
|
||||||
|
const agentFn: JsonCasAgentFn = async (role, _sp, _snap) => {
|
||||||
|
if (role === "checker") {
|
||||||
|
checkerCallCount++;
|
||||||
|
if (checkerCallCount === 1) {
|
||||||
|
return { text: "found issue", meta: { status: "bad" }, react: null };
|
||||||
|
}
|
||||||
|
return { text: "all good now", meta: { status: "ok" }, react: null };
|
||||||
|
}
|
||||||
|
return { text: "fixed it", meta: { fix: "patched" }, react: null };
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await executeJsonCasThread({
|
||||||
|
workflowHash,
|
||||||
|
input: "check and fix",
|
||||||
|
moderatorRules: CONDITIONAL_WORKFLOW.moderator,
|
||||||
|
io: makeIo(store, typeHashes, "THREAD09"),
|
||||||
|
options: makeOptions(),
|
||||||
|
agentFn,
|
||||||
|
logger: noLogger(),
|
||||||
|
workflow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.returnCode).toBe(0);
|
||||||
|
expect(checkerCallCount).toBe(2);
|
||||||
|
|
||||||
|
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||||
|
const lastStep = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
|
||||||
|
expect(lastStep.role).toBe("checker");
|
||||||
|
|
||||||
|
const step2 = store.get(lastStep.previous!)!.payload as ThreadStepPayload;
|
||||||
|
expect(step2.role).toBe("fixer");
|
||||||
|
|
||||||
|
const step1 = store.get(step2.previous!)!.payload as ThreadStepPayload;
|
||||||
|
expect(step1.role).toBe("checker");
|
||||||
|
expect(step1.previous).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("immediate END from moderator still produces a valid thread", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
|
||||||
|
const immediateEnd: WorkflowInput = {
|
||||||
|
name: "test-immediate-end",
|
||||||
|
description: "Ends immediately",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Never called",
|
||||||
|
systemPrompt: "N/A",
|
||||||
|
extractPrompt: "N/A",
|
||||||
|
schema: { type: "object" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
moderator: [{ from: START, to: END, when: null }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { workflowHash } = await setupWorkflow(store, typeHashes, immediateEnd);
|
||||||
|
|
||||||
|
const agentFn: JsonCasAgentFn = async (): Promise<never> => {
|
||||||
|
throw new Error("should not be called");
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await executeJsonCasThread({
|
||||||
|
workflowHash,
|
||||||
|
input: "skip",
|
||||||
|
moderatorRules: immediateEnd.moderator,
|
||||||
|
io: makeIo(store, typeHashes, "THREAD10"),
|
||||||
|
options: makeOptions(),
|
||||||
|
agentFn,
|
||||||
|
logger: noLogger(),
|
||||||
|
workflow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.returnCode).toBe(0);
|
||||||
|
|
||||||
|
const endNode = store.get(result.rootHash);
|
||||||
|
expect(endNode).not.toBeNull();
|
||||||
|
const endPayload = endNode!.payload as ThreadEndPayload;
|
||||||
|
expect(endPayload.start).toBeTruthy();
|
||||||
|
expect(endPayload.lastStep).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("abort handling", () => {
|
||||||
|
test("aborted signal produces returnCode 130", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
ac.abort();
|
||||||
|
|
||||||
|
const agentFn: JsonCasAgentFn = async (): Promise<never> => {
|
||||||
|
throw new Error("should not be called");
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await executeJsonCasThread({
|
||||||
|
workflowHash,
|
||||||
|
input: "will abort",
|
||||||
|
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
|
||||||
|
io: makeIo(store, typeHashes, "THREAD11"),
|
||||||
|
options: makeOptions({ signal: ac.signal }),
|
||||||
|
agentFn,
|
||||||
|
logger: noLogger(),
|
||||||
|
workflow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.returnCode).toBe(130);
|
||||||
|
expect(result.summary).toContain("abort");
|
||||||
|
|
||||||
|
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||||
|
expect(endPayload.returnCode).toBe(130);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("agent receives correct context", () => {
|
||||||
|
test("agent receives role name, system prompt, and accumulated steps", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||||
|
|
||||||
|
const { loadWorkflow } = await import("@uncaged/workflow-json-def");
|
||||||
|
const hydrated = loadWorkflow(store, typeHashes, workflowHash);
|
||||||
|
|
||||||
|
const receivedCalls: Array<{
|
||||||
|
role: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
stepCount: number;
|
||||||
|
input: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const agentFn: JsonCasAgentFn = async (role, systemPrompt, snapshot) => {
|
||||||
|
receivedCalls.push({
|
||||||
|
role,
|
||||||
|
systemPrompt,
|
||||||
|
stepCount: snapshot.steps.length,
|
||||||
|
input: snapshot.start.input,
|
||||||
|
});
|
||||||
|
return { text: `output for ${role}`, meta: {}, react: null };
|
||||||
|
};
|
||||||
|
|
||||||
|
await executeJsonCasThread({
|
||||||
|
workflowHash,
|
||||||
|
input: "my prompt",
|
||||||
|
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||||
|
io: makeIo(store, typeHashes, "THREAD12"),
|
||||||
|
options: makeOptions(),
|
||||||
|
agentFn,
|
||||||
|
logger: noLogger(),
|
||||||
|
workflow: hydrated,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(receivedCalls.length).toBe(2);
|
||||||
|
|
||||||
|
expect(receivedCalls[0]!.role).toBe("planner");
|
||||||
|
expect(receivedCalls[0]!.systemPrompt).toBe("You are a planner.");
|
||||||
|
expect(receivedCalls[0]!.stepCount).toBe(0);
|
||||||
|
expect(receivedCalls[0]!.input).toBe("my prompt");
|
||||||
|
|
||||||
|
expect(receivedCalls[1]!.role).toBe("coder");
|
||||||
|
expect(receivedCalls[1]!.systemPrompt).toBe("You are a coder.");
|
||||||
|
expect(receivedCalls[1]!.stepCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("snapshot accumulates step meta from previous rounds", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const { workflowHash } = await setupWorkflow(store, typeHashes, CONDITIONAL_WORKFLOW);
|
||||||
|
|
||||||
|
let round = 0;
|
||||||
|
const snapshots: Array<{
|
||||||
|
role: string;
|
||||||
|
steps: readonly { role: string; meta: Record<string, unknown> }[];
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const agentFn: JsonCasAgentFn = async (role, _sp, snapshot) => {
|
||||||
|
snapshots.push({ role, steps: [...snapshot.steps] });
|
||||||
|
round++;
|
||||||
|
if (role === "checker") {
|
||||||
|
return round === 1
|
||||||
|
? { text: "bad", meta: { status: "bad" }, react: null }
|
||||||
|
: { text: "ok", meta: { status: "ok" }, react: null };
|
||||||
|
}
|
||||||
|
return { text: "fixed", meta: { fix: "yes" }, react: null };
|
||||||
|
};
|
||||||
|
|
||||||
|
await executeJsonCasThread({
|
||||||
|
workflowHash,
|
||||||
|
input: "go",
|
||||||
|
moderatorRules: CONDITIONAL_WORKFLOW.moderator,
|
||||||
|
io: makeIo(store, typeHashes, "THREAD13"),
|
||||||
|
options: makeOptions(),
|
||||||
|
agentFn,
|
||||||
|
logger: noLogger(),
|
||||||
|
workflow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshots.length).toBe(3);
|
||||||
|
|
||||||
|
expect(snapshots[0]!.steps.length).toBe(0);
|
||||||
|
|
||||||
|
expect(snapshots[1]!.steps.length).toBe(1);
|
||||||
|
expect(snapshots[1]!.steps[0]!.role).toBe("checker");
|
||||||
|
expect(snapshots[1]!.steps[0]!.meta).toEqual({ status: "bad" });
|
||||||
|
|
||||||
|
expect(snapshots[2]!.steps.length).toBe(2);
|
||||||
|
expect(snapshots[2]!.steps[0]!.role).toBe("checker");
|
||||||
|
expect(snapshots[2]!.steps[1]!.role).toBe("fixer");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildJsonCasThreadSnapshot", () => {
|
||||||
|
test("builds snapshot from start + step chain", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||||
|
|
||||||
|
const agentFn = createMockAgent({
|
||||||
|
planner: { text: "plan text", meta: { plan: "alpha" } },
|
||||||
|
coder: { text: "code text", meta: { code: "beta" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeJsonCasThread({
|
||||||
|
workflowHash,
|
||||||
|
input: "build it",
|
||||||
|
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||||
|
io: makeIo(store, typeHashes, "THREAD_SNAP"),
|
||||||
|
options: makeOptions(),
|
||||||
|
agentFn,
|
||||||
|
logger: noLogger(),
|
||||||
|
workflow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||||
|
const startHash = endPayload.start;
|
||||||
|
const lastStepHash = endPayload.lastStep;
|
||||||
|
|
||||||
|
const snapshot = buildJsonCasThreadSnapshot(
|
||||||
|
store,
|
||||||
|
typeHashes,
|
||||||
|
startHash,
|
||||||
|
lastStepHash,
|
||||||
|
"THREAD_SNAP",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(snapshot.threadId).toBe("THREAD_SNAP");
|
||||||
|
expect(snapshot.start.input).toBe("build it");
|
||||||
|
expect(snapshot.start.workflowHash).toBe(workflowHash);
|
||||||
|
expect(snapshot.steps.length).toBe(2);
|
||||||
|
expect(snapshot.steps[0]!.role).toBe("planner");
|
||||||
|
expect(snapshot.steps[0]!.meta).toEqual({ plan: "alpha" });
|
||||||
|
expect(snapshot.steps[1]!.role).toBe("coder");
|
||||||
|
expect(snapshot.steps[1]!.meta).toEqual({ code: "beta" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builds snapshot with null headStepHash (start only)", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||||
|
|
||||||
|
const startHash = await store.put(typeHashes.threadStart, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
input: "just started",
|
||||||
|
depth: 0,
|
||||||
|
parentThread: null,
|
||||||
|
agents: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = buildJsonCasThreadSnapshot(store, typeHashes, startHash, null, "THREAD_SNAP2");
|
||||||
|
|
||||||
|
expect(snapshot.threadId).toBe("THREAD_SNAP2");
|
||||||
|
expect(snapshot.start.input).toBe("just started");
|
||||||
|
expect(snapshot.steps.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildJsonCasThreadContext", () => {
|
||||||
|
test("builds a protocol-compatible ThreadContext", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||||
|
|
||||||
|
const agentFn = createMockAgent({
|
||||||
|
planner: { text: "plan text", meta: { plan: "ctx-test" } },
|
||||||
|
coder: { text: "code text", meta: { code: "ctx-done" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeJsonCasThread({
|
||||||
|
workflowHash,
|
||||||
|
input: "context test",
|
||||||
|
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||||
|
io: makeIo(store, typeHashes, "THREAD_CTX"),
|
||||||
|
options: makeOptions({ depth: 3 }),
|
||||||
|
agentFn,
|
||||||
|
logger: noLogger(),
|
||||||
|
workflow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||||
|
const ctx = buildJsonCasThreadContext(store, typeHashes, endPayload.start, endPayload.lastStep);
|
||||||
|
|
||||||
|
expect(ctx.threadId).toBe("");
|
||||||
|
expect(ctx.depth).toBe(3);
|
||||||
|
expect(ctx.bundleHash).toBe(workflowHash);
|
||||||
|
expect(ctx.start.role).toBe("__start__");
|
||||||
|
expect(ctx.start.content).toBe("context test");
|
||||||
|
expect(ctx.steps.length).toBe(2);
|
||||||
|
expect(ctx.steps[0]!.role).toBe("planner");
|
||||||
|
expect(ctx.steps[0]!.meta).toEqual({ plan: "ctx-test" });
|
||||||
|
expect(ctx.steps[1]!.role).toBe("coder");
|
||||||
|
expect(ctx.steps[1]!.meta).toEqual({ code: "ctx-done" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("context from start-only thread has empty steps", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||||
|
|
||||||
|
const startHash = await store.put(typeHashes.threadStart, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
input: "start only",
|
||||||
|
depth: 0,
|
||||||
|
parentThread: null,
|
||||||
|
agents: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = buildJsonCasThreadContext(store, typeHashes, startHash, null);
|
||||||
|
|
||||||
|
expect(ctx.start.content).toBe("start only");
|
||||||
|
expect(ctx.steps.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readContentText", () => {
|
||||||
|
test("reads text from a content node", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const hash = await store.put(typeHashes.content, { text: "hello world" });
|
||||||
|
|
||||||
|
const text = readContentText(store, hash);
|
||||||
|
expect(text).toBe("hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for missing hash", async () => {
|
||||||
|
const { store } = await setupStore();
|
||||||
|
const text = readContentText(store, "NONEXISTENT0001");
|
||||||
|
expect(text).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CAS graph integrity", () => {
|
||||||
|
test("all nodes are reachable via walk from thread-end", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
|
||||||
|
|
||||||
|
const agentFn = createMockAgent({
|
||||||
|
planner: { text: "plan", meta: { plan: "x" } },
|
||||||
|
coder: { text: "code", meta: { code: "y" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeJsonCasThread({
|
||||||
|
workflowHash,
|
||||||
|
input: "walk test",
|
||||||
|
moderatorRules: SIMPLE_WORKFLOW.moderator,
|
||||||
|
io: makeIo(store, typeHashes, "THREAD_WALK"),
|
||||||
|
options: makeOptions(),
|
||||||
|
agentFn,
|
||||||
|
logger: noLogger(),
|
||||||
|
workflow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const visited = new Set<string>();
|
||||||
|
walk(store, result.rootHash, (hash) => {
|
||||||
|
visited.add(hash);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(visited.has(result.rootHash)).toBe(true);
|
||||||
|
|
||||||
|
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||||
|
expect(visited.has(endPayload.start)).toBe(true);
|
||||||
|
expect(visited.has(endPayload.lastStep)).toBe(true);
|
||||||
|
|
||||||
|
const step2 = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
|
||||||
|
expect(visited.has(step2.content)).toBe(true);
|
||||||
|
expect(visited.has(step2.react)).toBe(true);
|
||||||
|
expect(visited.has(step2.start)).toBe(true);
|
||||||
|
|
||||||
|
if (step2.previous !== null) {
|
||||||
|
expect(visited.has(step2.previous)).toBe(true);
|
||||||
|
const step1 = store.get(step2.previous)!.payload as ThreadStepPayload;
|
||||||
|
expect(visited.has(step1.content)).toBe(true);
|
||||||
|
expect(visited.has(step1.react)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("react session nodes have empty structure when agent returns react: null", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
|
||||||
|
|
||||||
|
const agentFn = createMockAgent({
|
||||||
|
worker: { text: "w", meta: { result: "r" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeJsonCasThread({
|
||||||
|
workflowHash,
|
||||||
|
input: "react check",
|
||||||
|
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
|
||||||
|
io: makeIo(store, typeHashes, "THREAD_REACT"),
|
||||||
|
options: makeOptions(),
|
||||||
|
agentFn,
|
||||||
|
logger: noLogger(),
|
||||||
|
workflow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
|
||||||
|
const stepPayload = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
|
||||||
|
const reactNode = store.get(stepPayload.react);
|
||||||
|
|
||||||
|
expect(reactNode).not.toBeNull();
|
||||||
|
const reactPayload = reactNode!.payload as Record<string, unknown>;
|
||||||
|
expect(reactPayload.turns).toEqual([]);
|
||||||
|
expect(reactPayload.totalTokens).toBe(0);
|
||||||
|
expect(reactPayload.durationMs).toBe(0);
|
||||||
|
expect(reactPayload.role).toBe("worker");
|
||||||
|
expect(typeof reactPayload.agent).toBe("string");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { createMemoryStore } from "@uncaged/json-cas";
|
||||||
|
import {
|
||||||
|
type ContentPayload,
|
||||||
|
type ReactSessionPayload,
|
||||||
|
type ReactToolCallPayload,
|
||||||
|
type ReactTurnPayload,
|
||||||
|
registerWorkflowSchemas,
|
||||||
|
} from "@uncaged/json-cas-workflow";
|
||||||
|
|
||||||
|
import { writeReactSession } from "../src/engine/json-cas-react-recorder.js";
|
||||||
|
import type { ReactTrace } from "../src/engine/json-cas-types.js";
|
||||||
|
|
||||||
|
// ── Fixtures ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function setupStore() {
|
||||||
|
const store = createMemoryStore();
|
||||||
|
const typeHashes = await registerWorkflowSchemas(store);
|
||||||
|
return { store, typeHashes };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeFakeAgent(
|
||||||
|
store: Awaited<ReturnType<typeof setupStore>>["store"],
|
||||||
|
typeHashes: Awaited<ReturnType<typeof setupStore>>["typeHashes"],
|
||||||
|
) {
|
||||||
|
return store.put(typeHashes.agent, {
|
||||||
|
package: "test-agent",
|
||||||
|
version: "1.0.0",
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("writeReactSession", () => {
|
||||||
|
describe("empty trace", () => {
|
||||||
|
test("produces a react-session with zero turns", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const agentHash = await makeFakeAgent(store, typeHashes);
|
||||||
|
|
||||||
|
const trace: ReactTrace = { turns: [], totalTokens: 0, durationMs: 0 };
|
||||||
|
|
||||||
|
const sessionHash = await writeReactSession(store, typeHashes, {
|
||||||
|
agentHash,
|
||||||
|
role: "worker",
|
||||||
|
trace,
|
||||||
|
});
|
||||||
|
|
||||||
|
const node = store.get(sessionHash);
|
||||||
|
expect(node).not.toBeNull();
|
||||||
|
const payload = node!.payload as ReactSessionPayload;
|
||||||
|
expect(payload.agent).toBe(agentHash);
|
||||||
|
expect(payload.role).toBe("worker");
|
||||||
|
expect(payload.turns).toEqual([]);
|
||||||
|
expect(payload.totalTokens).toBe(0);
|
||||||
|
expect(payload.durationMs).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("single turn, no tool calls", () => {
|
||||||
|
test("produces react-session → react-turn → content nodes", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const agentHash = await makeFakeAgent(store, typeHashes);
|
||||||
|
|
||||||
|
const trace: ReactTrace = {
|
||||||
|
turns: [
|
||||||
|
{
|
||||||
|
input: "What is 2+2?",
|
||||||
|
output: "4",
|
||||||
|
toolCalls: [],
|
||||||
|
tokens: { input: 10, output: 5 },
|
||||||
|
latencyMs: 200,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTokens: 15,
|
||||||
|
durationMs: 200,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionHash = await writeReactSession(store, typeHashes, {
|
||||||
|
agentHash,
|
||||||
|
role: "solver",
|
||||||
|
trace,
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
|
||||||
|
expect(session.turns.length).toBe(1);
|
||||||
|
expect(session.totalTokens).toBe(15);
|
||||||
|
expect(session.durationMs).toBe(200);
|
||||||
|
expect(session.role).toBe("solver");
|
||||||
|
|
||||||
|
const turnHash = session.turns[0]!;
|
||||||
|
const turn = store.get(turnHash)!.payload as ReactTurnPayload;
|
||||||
|
expect(turn.toolCalls).toEqual([]);
|
||||||
|
expect(turn.tokens).toEqual({ input: 10, output: 5 });
|
||||||
|
expect(turn.latencyMs).toBe(200);
|
||||||
|
|
||||||
|
const inputContent = store.get(turn.input)!.payload as ContentPayload;
|
||||||
|
expect(inputContent.text).toBe("What is 2+2?");
|
||||||
|
|
||||||
|
const outputContent = store.get(turn.output)!.payload as ContentPayload;
|
||||||
|
expect(outputContent.text).toBe("4");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("single turn with tool calls", () => {
|
||||||
|
test("serialises tool calls to react-tool-call → content nodes", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const agentHash = await makeFakeAgent(store, typeHashes);
|
||||||
|
|
||||||
|
const trace: ReactTrace = {
|
||||||
|
turns: [
|
||||||
|
{
|
||||||
|
input: "Search for cats",
|
||||||
|
output: "Found 42 cats",
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
name: "search",
|
||||||
|
arguments: '{"query":"cats"}',
|
||||||
|
result: '{"count":42}',
|
||||||
|
durationMs: 80,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tokens: { input: 20, output: 10 },
|
||||||
|
latencyMs: 350,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTokens: 30,
|
||||||
|
durationMs: 350,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionHash = await writeReactSession(store, typeHashes, {
|
||||||
|
agentHash,
|
||||||
|
role: "searcher",
|
||||||
|
trace,
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
|
||||||
|
const turn = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
|
||||||
|
expect(turn.toolCalls.length).toBe(1);
|
||||||
|
|
||||||
|
const toolCall = store.get(turn.toolCalls[0]!)!.payload as ReactToolCallPayload;
|
||||||
|
expect(toolCall.name).toBe("search");
|
||||||
|
expect(toolCall.durationMs).toBe(80);
|
||||||
|
|
||||||
|
const argsContent = store.get(toolCall.arguments)!.payload as ContentPayload;
|
||||||
|
expect(argsContent.text).toBe('{"query":"cats"}');
|
||||||
|
|
||||||
|
const resultContent = store.get(toolCall.result)!.payload as ContentPayload;
|
||||||
|
expect(resultContent.text).toBe('{"count":42}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multiple tool calls in one turn are all recorded", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const agentHash = await makeFakeAgent(store, typeHashes);
|
||||||
|
|
||||||
|
const trace: ReactTrace = {
|
||||||
|
turns: [
|
||||||
|
{
|
||||||
|
input: "Do two things",
|
||||||
|
output: "Done",
|
||||||
|
toolCalls: [
|
||||||
|
{ name: "tool_a", arguments: '{"x":1}', result: '"ok_a"', durationMs: 10 },
|
||||||
|
{ name: "tool_b", arguments: '{"y":2}', result: '"ok_b"', durationMs: 20 },
|
||||||
|
],
|
||||||
|
tokens: { input: 5, output: 3 },
|
||||||
|
latencyMs: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTokens: 8,
|
||||||
|
durationMs: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionHash = await writeReactSession(store, typeHashes, {
|
||||||
|
agentHash,
|
||||||
|
role: "doer",
|
||||||
|
trace,
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
|
||||||
|
const turn = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
|
||||||
|
expect(turn.toolCalls.length).toBe(2);
|
||||||
|
|
||||||
|
const tc0 = store.get(turn.toolCalls[0]!)!.payload as ReactToolCallPayload;
|
||||||
|
expect(tc0.name).toBe("tool_a");
|
||||||
|
|
||||||
|
const tc1 = store.get(turn.toolCalls[1]!)!.payload as ReactToolCallPayload;
|
||||||
|
expect(tc1.name).toBe("tool_b");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multiple turns", () => {
|
||||||
|
test("each turn is stored as a separate react-turn node", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const agentHash = await makeFakeAgent(store, typeHashes);
|
||||||
|
|
||||||
|
const trace: ReactTrace = {
|
||||||
|
turns: [
|
||||||
|
{
|
||||||
|
input: "Round 1 prompt",
|
||||||
|
output: "Round 1 response",
|
||||||
|
toolCalls: [],
|
||||||
|
tokens: { input: 10, output: 8 },
|
||||||
|
latencyMs: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "Round 2 prompt",
|
||||||
|
output: "Round 2 response",
|
||||||
|
toolCalls: [],
|
||||||
|
tokens: { input: 12, output: 6 },
|
||||||
|
latencyMs: 120,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTokens: 36,
|
||||||
|
durationMs: 220,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionHash = await writeReactSession(store, typeHashes, {
|
||||||
|
agentHash,
|
||||||
|
role: "multi",
|
||||||
|
trace,
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
|
||||||
|
expect(session.turns.length).toBe(2);
|
||||||
|
expect(session.totalTokens).toBe(36);
|
||||||
|
expect(session.durationMs).toBe(220);
|
||||||
|
|
||||||
|
// Turns must be distinct nodes
|
||||||
|
expect(session.turns[0]).not.toBe(session.turns[1]);
|
||||||
|
|
||||||
|
const turn0 = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
|
||||||
|
expect((store.get(turn0.input)!.payload as ContentPayload).text).toBe("Round 1 prompt");
|
||||||
|
expect(turn0.tokens).toEqual({ input: 10, output: 8 });
|
||||||
|
|
||||||
|
const turn1 = store.get(session.turns[1]!)!.payload as ReactTurnPayload;
|
||||||
|
expect((store.get(turn1.input)!.payload as ContentPayload).text).toBe("Round 2 prompt");
|
||||||
|
expect(turn1.tokens).toEqual({ input: 12, output: 6 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("token and duration values", () => {
|
||||||
|
test("token counts and latency are preserved exactly", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const agentHash = await makeFakeAgent(store, typeHashes);
|
||||||
|
|
||||||
|
const trace: ReactTrace = {
|
||||||
|
turns: [
|
||||||
|
{
|
||||||
|
input: "p",
|
||||||
|
output: "r",
|
||||||
|
toolCalls: [],
|
||||||
|
tokens: { input: 9999, output: 1234 },
|
||||||
|
latencyMs: 5678,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTokens: 11233,
|
||||||
|
durationMs: 5678,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionHash = await writeReactSession(store, typeHashes, {
|
||||||
|
agentHash,
|
||||||
|
role: "counter",
|
||||||
|
trace,
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
|
||||||
|
expect(session.totalTokens).toBe(11233);
|
||||||
|
expect(session.durationMs).toBe(5678);
|
||||||
|
|
||||||
|
const turn = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
|
||||||
|
expect(turn.tokens.input).toBe(9999);
|
||||||
|
expect(turn.tokens.output).toBe(1234);
|
||||||
|
expect(turn.latencyMs).toBe(5678);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("writeReactSession + executeJsonCasThread integration", () => {
|
||||||
|
test("engine stores real react session when agent provides react trace", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const { registerWorkflow } = await import("@uncaged/workflow-json-def");
|
||||||
|
const { executeJsonCasThread } = await import("../src/engine/json-cas-engine.js");
|
||||||
|
type JsonCasAgentFn = import("../src/engine/json-cas-types.js").JsonCasAgentFn;
|
||||||
|
|
||||||
|
const workflowHash = await registerWorkflow(store, typeHashes, {
|
||||||
|
name: "react-test",
|
||||||
|
description: "Tests react instrumentation",
|
||||||
|
roles: {
|
||||||
|
solver: {
|
||||||
|
description: "Solves",
|
||||||
|
systemPrompt: "Solve it.",
|
||||||
|
extractPrompt: "Extract.",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["answer"],
|
||||||
|
properties: { answer: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
moderator: [
|
||||||
|
{ from: "__start__", to: "solver", when: null },
|
||||||
|
{ from: "solver", to: "__end__", when: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentFn: JsonCasAgentFn = async () => ({
|
||||||
|
text: "The answer is 42",
|
||||||
|
meta: { answer: "42" },
|
||||||
|
react: {
|
||||||
|
turns: [
|
||||||
|
{
|
||||||
|
input: "Solve it. What is the answer?",
|
||||||
|
output: "The answer is 42",
|
||||||
|
toolCalls: [],
|
||||||
|
tokens: { input: 15, output: 8 },
|
||||||
|
latencyMs: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalTokens: 23,
|
||||||
|
durationMs: 300,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeJsonCasThread({
|
||||||
|
workflowHash,
|
||||||
|
input: "What is the answer?",
|
||||||
|
moderatorRules: [
|
||||||
|
{ from: "__start__", to: "solver", when: null },
|
||||||
|
{ from: "solver", to: "__end__", when: null },
|
||||||
|
],
|
||||||
|
io: { threadId: "REACT_INTEG", store, typeHashes },
|
||||||
|
options: { depth: 0, parentThread: null, signal: new AbortController().signal, agents: {} },
|
||||||
|
agentFn,
|
||||||
|
logger: () => {},
|
||||||
|
workflow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const endPayload = store.get(result.rootHash)!
|
||||||
|
.payload as import("@uncaged/json-cas-workflow").ThreadEndPayload;
|
||||||
|
const stepPayload = store.get(endPayload.lastStep)!
|
||||||
|
.payload as import("@uncaged/json-cas-workflow").ThreadStepPayload;
|
||||||
|
const session = store.get(stepPayload.react)!.payload as ReactSessionPayload;
|
||||||
|
|
||||||
|
expect(session.turns.length).toBe(1);
|
||||||
|
expect(session.totalTokens).toBe(23);
|
||||||
|
expect(session.durationMs).toBe(300);
|
||||||
|
expect(session.role).toBe("solver");
|
||||||
|
|
||||||
|
const turn = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
|
||||||
|
expect(turn.tokens).toEqual({ input: 15, output: 8 });
|
||||||
|
expect(turn.latencyMs).toBe(300);
|
||||||
|
expect((store.get(turn.input)!.payload as ContentPayload).text).toBe(
|
||||||
|
"Solve it. What is the answer?",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("engine falls back to empty react-session when react is null", async () => {
|
||||||
|
const { store, typeHashes } = await setupStore();
|
||||||
|
const { registerWorkflow } = await import("@uncaged/workflow-json-def");
|
||||||
|
const { executeJsonCasThread } = await import("../src/engine/json-cas-engine.js");
|
||||||
|
type JsonCasAgentFn = import("../src/engine/json-cas-types.js").JsonCasAgentFn;
|
||||||
|
|
||||||
|
const workflowHash = await registerWorkflow(store, typeHashes, {
|
||||||
|
name: "null-react-test",
|
||||||
|
description: "Tests null react fallback",
|
||||||
|
roles: {
|
||||||
|
worker: {
|
||||||
|
description: "Works",
|
||||||
|
systemPrompt: "Work.",
|
||||||
|
extractPrompt: "Extract.",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["result"],
|
||||||
|
properties: { result: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
moderator: [
|
||||||
|
{ from: "__start__", to: "worker", when: null },
|
||||||
|
{ from: "worker", to: "__end__", when: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentFn: JsonCasAgentFn = async () => ({
|
||||||
|
text: "done",
|
||||||
|
meta: { result: "done" },
|
||||||
|
react: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeJsonCasThread({
|
||||||
|
workflowHash,
|
||||||
|
input: "do it",
|
||||||
|
moderatorRules: [
|
||||||
|
{ from: "__start__", to: "worker", when: null },
|
||||||
|
{ from: "worker", to: "__end__", when: null },
|
||||||
|
],
|
||||||
|
io: { threadId: "NULL_REACT", store, typeHashes },
|
||||||
|
options: { depth: 0, parentThread: null, signal: new AbortController().signal, agents: {} },
|
||||||
|
agentFn,
|
||||||
|
logger: () => {},
|
||||||
|
workflow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const endPayload = store.get(result.rootHash)!
|
||||||
|
.payload as import("@uncaged/json-cas-workflow").ThreadEndPayload;
|
||||||
|
const stepPayload = store.get(endPayload.lastStep)!
|
||||||
|
.payload as import("@uncaged/json-cas-workflow").ThreadStepPayload;
|
||||||
|
const session = store.get(stepPayload.react)!.payload as ReactSessionPayload;
|
||||||
|
|
||||||
|
expect(session.turns).toEqual([]);
|
||||||
|
expect(session.totalTokens).toBe(0);
|
||||||
|
expect(session.durationMs).toBe(0);
|
||||||
|
expect(session.role).toBe("worker");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-execute",
|
"name": "@uncaged/workflow-execute",
|
||||||
"version": "0.4.5",
|
"version": "0.5.0-alpha.4",
|
||||||
"files": [
|
"files": [
|
||||||
"src",
|
"src",
|
||||||
"dist",
|
"dist",
|
||||||
@@ -24,6 +24,9 @@
|
|||||||
"@uncaged/workflow-cas": "workspace:^",
|
"@uncaged/workflow-cas": "workspace:^",
|
||||||
"@uncaged/workflow-reactor": "workspace:^",
|
"@uncaged/workflow-reactor": "workspace:^",
|
||||||
"@uncaged/workflow-register": "workspace:^",
|
"@uncaged/workflow-register": "workspace:^",
|
||||||
|
"@uncaged/json-cas": "file:../../../json-cas/packages/json-cas",
|
||||||
|
"@uncaged/json-cas-workflow": "file:../../../json-cas/packages/json-cas-workflow",
|
||||||
|
"@uncaged/workflow-json-def": "workspace:^",
|
||||||
"yaml": "^2.7.1"
|
"yaml": "^2.7.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user