diff --git a/orchestrator-v2/README.md b/orchestrator-v2/README.md index b03e514..673f4dd 100644 --- a/orchestrator-v2/README.md +++ b/orchestrator-v2/README.md @@ -4,16 +4,16 @@ A rebuild of the Bifrost orchestrator as a thin **get-work + dispatch** system. ## Packages -| Package | Purpose | -| -------------------------------- | -------------------------------------------------------------------- | -| `@bifrost-ai/interfaces-work` | Work item, handler, and execution contracts | -| `@bifrost-ai/protocol` | Signed WebSocket RPC between orchestrator and runners | -| `@bifrost-ai/orchestrator` | Thin orchestrator: stream work items, dispatch, record outcomes | -| `@bifrost-ai/runner` | Remote runner: config-driven dial, execute handlers, report outcomes | -| `@bifrost-ai/engine` | Engine interface, types, and `TestEngine` for development/testing | -| `@bifrost-ai/engine-claude-code` | Claude Code Agent SDK engine (`ClaudeCodeEngine`) | -| `@bifrost-ai/engine-cursor` | Cursor SDK engine (`CursorEngine`) | -| `@bifrost-ai/agent-3-task` | Task Agent — single-shot LLM execution (`kind: "task"`) | +| Package | Purpose | +| -------------------------------- | ----------------------------------------------------------------- | +| `@bifrost-ai/interfaces-work` | Work item, handler, and execution contracts | +| `@bifrost-ai/protocol` | Signed WebSocket RPC between orchestrator and runners | +| `@bifrost-ai/orchestrator` | Thin orchestrator: stream work items, dispatch, record outcomes | +| `@bifrost-ai/runner` | Remote runner: script stack execution, conventions, decorators | +| `@bifrost-ai/engine` | Engine interface, types, and `TestEngine` for development/testing | +| `@bifrost-ai/engine-claude-code` | Claude Code Agent SDK engine (`ClaudeCodeEngine`) | +| `@bifrost-ai/engine-cursor` | Cursor SDK engine (`CursorEngine`) | +| `@bifrost-ai/agent-3-task` | Task Agent — single-shot LLM execution (registered as scripts) | For design background and how each piece fits together, see [docs/](docs/). @@ -36,32 +36,32 @@ vp run -r build # build all packages ## Usage -### Define a work item handler +### Register a script on the runner -Handlers are registered on the runner and executed when a matching work item is dispatched. Higher-level agents (Task Agent, Workflow Agent) build on this interface. +Scripts and decorators are registered on the runner. Dispatch resolves `workItem.kind` to a script and `workItem.flow` to an ordered list of decorators. Runner-level **conventions** wrap every execution (including the built-in `failOnError` decorator). ```typescript -import type { WorkItemHandler } from "@bifrost-ai/interfaces-work"; - -const echo: WorkItemHandler = { - kind: "script", - name: "echo", - async run(workItem, ctx) { - const message = workItem.metadata.message as string; - await ctx.setState({ echoed: message }); - return { outcome: "completed", message }; - }, +import type { ScriptFn } from "@bifrost-ai/interfaces-work"; +import { Runner } from "@bifrost-ai/runner"; + +const echo: ScriptFn = async (workItem, ctx) => { + const message = workItem.metadata.message as string; + await ctx.setState({ echoed: message }); + return { outcome: "completed", message }; }; + +const runner = new Runner(); +runner.registerScript("echo", echo); ``` -A handler receives: +A script receives: -- `workItem` — the dispatched instance (`workItemId`, `kind`, `name`, `state`, `metadata`) -- `ctx.data` — `get(type)` returns a typed `Registry`, then `.get(name)` for the instance -- `ctx.handlers` — `get(kind, name)` for other registered handlers +- `workItem` — the dispatched instance (`workItemId`, `kind`, `flow`, `state`, `metadata`) +- `ctx.cwd` — working directory for local execution +- `ctx.data` — typed sub-registries (`ctx.data.get("engine").get("claude")`) - `ctx.setState(state)` — persist state updates back to the work item source -It returns `{ outcome: "completed" | "failed" | "paused", message?, telemetry? }`. A thrown error is treated as `failed`. +It returns `{ outcome: "completed" | "failed" | "paused", message?, telemetry? }` or any other value (treated as completed). Thrown errors are caught by the `failOnError` convention. ### Implement a work item source @@ -74,8 +74,8 @@ const workItemSource: WorkItemSource = { async *watchWorkItems() { yield { workItemId: "work-item-1", - kind: "script", - name: "echo", + kind: "echo", + flow: [], state: {}, metadata: { message: "hello" }, } satisfies WorkItem; diff --git a/orchestrator-v2/docs/runner.md b/orchestrator-v2/docs/runner.md index 794cc7d..3e7bbfc 100644 --- a/orchestrator-v2/docs/runner.md +++ b/orchestrator-v2/docs/runner.md @@ -12,17 +12,17 @@ The `@bifrost-ai/runner` package provides a config-first `Runner` class: - Dials the orchestrator on `start()`, sends periodic heartbeats - Validates every inbound orchestrator frame against the configured orchestrator public key -- Executes registered work item handlers locally with an RPC-backed `WorkItemExecutionContext` +- Executes registered scripts locally through a composable decorator stack with RPC-backed `ScriptContext` - Reports terminal outcomes (`workItem.complete`, `workItem.fail`, `workItem.pause`) over signed RPC ### Developer workflow -Scripts and other runnable agents are enrolled incrementally. Create the data registry up front with type guards, then register instances into typed sub-registries. +Scripts, decorators, and task agents are enrolled incrementally. Create the data registry up front with type guards, then register scripts and optional decorators. ```typescript import { Runner, createDataRegistry } from "@bifrost-ai/runner"; import "@bifrost-ai/agent-3-task/augment"; -import { echo } from "./scripts/echo.js"; +import { doSomething } from "./scripts/doSomething.js"; import { loadAgent, taskAgentDataGuards } from "@bifrost-ai/agent-3-task"; const data = createDataRegistry(taskAgentDataGuards); @@ -30,23 +30,25 @@ const runner = new Runner({ data }); runner.registerEngine("claude", claudeEngine); runner.registerTaskAgent("reviewer", await loadAgent("./agents/reviewer/AGENT.md")); -runner.registerScriptAgent("echo", echo); +runner.registerScriptAgent("doSomething", doSomething); await runner.start(); ``` -`registerTaskAgent(name, agent)` registers the handler under the dispatch name and stores the definition in `data.get("agentDefinition")` for lookup by other agents. +`registerTaskAgent(name, agent)` registers a script under `kind: name` and stores the definition in `data.get("agentDefinition")`. When `runner.yaml` (or `.bifrost-runner.yaml`) is present, keys and orchestrator URL are loaded automatically inside `start()` — no manual key handling required. -### Registry model +### Script stack model -| Kind | Setup | Dispatch | Handler access | -| ----------- | ------------------------------------------------------------------- | --------------------------------- | -------------------------------------- | -| **Data** | `createDataRegistry(guards)` then `.get(type).register(name, item)` | Not searched | `ctx.data.get(type).get(name)` — typed | -| **Handler** | `registerWorkItemHandler(handler)` | `workItem.kind` + `workItem.name` | `ctx.handlers.get(kind, name)` | +| Layer | Setup | Dispatch | +| -------------- | ------------------------------------------------------------------- | ------------------------------------------------------------ | +| **Data** | `createDataRegistry(guards)` then `.get(type).register(name, item)` | Available via `ctx.data` in scripts and decorators | +| **Script** | `registerScript(kind, fn)` | `workItem.kind` | +| **Decorator** | `registerDecorator(name, fn)` | Names in `workItem.flow` (outermost first) | +| **Convention** | `addConvention(name)` | Runner-level; wraps every work item (default: `failOnError`) | -Engines live in the data registry. Task agents (`kind: "task"`), scripts, and workflow agents are work item handlers. Dispatch looks up handlers by `(kind, name)`. +Engines live in the data registry. Task agents register as scripts. Dispatch composes `conventions → flow → script`. See [script-stack.md](temporal/script-stack.md). ### Config file (`runner.yaml`) diff --git a/orchestrator-v2/docs/temporal/script-stack-caveman.md b/orchestrator-v2/docs/temporal/script-stack-caveman.md new file mode 100644 index 0000000..5f68b2a --- /dev/null +++ b/orchestrator-v2/docs/temporal/script-stack-caveman.md @@ -0,0 +1,431 @@ +# Script Stack (Caveman) + +Even simpler than [script-stack-eli5.md](./script-stack-eli5.md). +Big brain version: [script-stack.md](./script-stack.md). + +--- + +## Problem + +Runner confused. Many rules. Many wires. New thing = more pain. + +Old way: + +``` +"Which code run?" +"Uh... ask layer three?" +"Layer three say ask layer four." +"Layer four say maybe layer two?" +*everyone scream* +``` + +New way: + +``` +"Which code run?" +"Look list." +"Ok." +``` + +--- + +## Idea + +Runner get **two list**. Not one list. **Two list.** + +| List | Caveman name | What it hold | +| ---------- | ------------- | --------------------------- | +| `scripts` | **job list** | name → do-thing function | +| `wrappers` | **coat list** | name → boss-around function | + +Work note (`WorkItem`) say: + +| Field | Caveman name | Meaning | +| ------------ | --------------- | -------------------------------------------- | +| `kind` | **main job** | which script run at center | +| `flow` | **coat order** | which wrappers outside-in. can be empty `[]` | +| `state` | **shared bag** | everyone put stuff here for next guy | +| `metadata` | **sticky note** | info about note. less touchy | +| `workItemId` | **note id** | so runner not mix up notes | + +``` + flow[0] coat (outside) + │ + flow[1] coat + │ + kind job (inside, meat) +``` + +**Outside coat see world first. Inside job do real work last.** + +--- + +## Script vs wrapper + +**Script** = do thing. + +> "Job here. Go." + +**Wrapper** = coat around job. + +> "Wait. Get ready. You go. I look. Good? Good." + +Wrapper **not** replace job. + +Wrapper get **`next`** button. + +Push `next` → inside run → more coats → then meat job at center. + +``` +coat one: "wait..." + coat two: "wait more..." + write-tests DO THING + coat two: "me look. ok." +coat one: "me look too. ok." +``` + +Real nesting (big brain whisper): + +```typescript +// flow: ["wrapper1", "wrapper2"], kind: "myScript" +wrapper1(item, () => wrapper2(item, () => myScriptFn(item))); +``` + +Caveman translation: + +``` +coat1(item, "hey coat2 you go") + → coat2(item, "hey job you go") + → job(item, "me do thing") +``` + +--- + +## Why `next` big deal + +`next` = **"go on. rest of stack now."** + +Wrapper is **boss** of inside. + +| Boss move | Caveman | What happen | +| --------------------- | ------------------------- | ---------------------------- | +| Never push `next` | "nah" | inside never run. job skip. | +| Push once | "go" | normal. setup → job → check. | +| Push many | "go again" | retry. stubborn job. | +| Push after change bag | "go but first me fix bag" | prepare then go | +| Look after push | "go... ok me inspect" | check after job | + +One button. Many power. Like fire. Respect fire. + +--- + +## Types (caveman read typescript) + +```typescript +type ScriptFn = (workItem: WorkItem) => Promise; +// job function. one argument: work note. return promise. + +type WrapperFn = (workItem: WorkItem, next: () => Promise) => Promise; +// coat function. two argument: work note + GO button. + +type ScriptStack = { + scripts: Record; + wrappers: Record; +}; +// runner pocket. two list. that all. +``` + +Caveman: + +- `ScriptFn` = `(note) => do work` +- `WrapperFn` = `(note, GO) => boss around` +- `ScriptStack` = runner pocket with job list + coat list + +--- + +## Wrapper cookbook (mmm coats) + +### 1. Retry — job break? try again + +```typescript +const retry = async (workItem, next) => { + let tries = 0; + while (true) { + try { + return await next(); + } catch (e) { + if (++tries >= 3) throw e; + } + } +}; +``` + +Caveman: "Try. Break? Try. Break? Try. Three time. Still break? **THROW ROCK.**" + +--- + +### 2. Prepare only — fix bag before go + +```typescript +const prepare = async (workItem, next) => { + workItem.state.tools = ["hammer", "rock"]; + return await next(); +}; +``` + +Caveman: "Me put hammer in bag. **Now you go.**" + +--- + +### 3. Check only — look after go + +```typescript +const check = async (workItem, next) => { + await next(); + if (!workItem.state.result) throw new Error("no result. bad."); +}; +``` + +Caveman: "You go. ... You back? **Where result?** No result? **ANGRY.**" + +--- + +### 4. Short-circuit — boss say no + +```typescript +const skipIfDone = async (workItem, next) => { + if (workItem.state.alreadyDone) return; // NO PUSH NEXT. job never run. + return await next(); +}; +``` + +Caveman: "Bag say already done? **Me go home.** Inside not run. Save energy. Good caveman." + +--- + +### 5. Log coat — me watch you + +```typescript +const log = async (workItem, next) => { + console.log("coat: before", workItem.kind); + const result = await next(); + console.log("coat: after", workItem.kind); + return result; +}; +``` + +Caveman: "Me yell BEFORE. You go. Me yell AFTER. Tribe happy." + +--- + +### 6. Timeout — too slow? me leave + +```typescript +const timeout = async (workItem, next) => { + const ms = 30_000; + let timer: NodeJS.Timeout; + const raced = Promise.race([ + next(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("too slow")), ms); + }), + ]); + try { + return await raced; + } finally { + clearTimeout(timer!); + } +}; +``` + +Caveman: "You go. Me count to thirty. Still not back? **ME LEAVE.**" + +--- + +### 7. Many coat — onion + +```typescript +const item = { + kind: "hunt", + flow: ["retry", "log", "typescript-tests"], +}; +``` + +Order outside → in: + +``` +retry + → log + → typescript-tests + → hunt (MEAT) +``` + +Caveman: "Retry outside. Log middle. TS coat inner. **Hunt at bone.**" + +--- + +## Write-tests (full story) + +`write-tests` = smart caveman. Know good test. Any language. Good. + +Workflow want **typescript** test. Old dumb way: + +``` +make write-tests-typescript-vitest-gwt-check-v2-final-FINAL.ts +make another one +make another one +tribe cry +``` + +New way: + +```typescript +const typescriptTests = async (workItem, next) => { + await prepareTsContext(workItem); // bag get vitest words + await next(); // write-tests run + await validateTsTests(workItem); // vitest run. should fail. good fail. +}; + +const item = { + kind: "write-tests", + flow: ["typescript-tests"], +}; + +const stack = { + scripts: { "write-tests": writeTestsFn }, + wrappers: { "typescript-tests": typescriptTests }, +}; +``` + +- **kind** = same job always (`write-tests`) +- **flow** = different coat per workflow (typescript, python, whatever) + +Same meat. Different fur. **No new job every time.** + +--- + +## Shared bag (`state`) + +Script and coat both touch **same bag**. + +```typescript +// coat put in bag +workItem.state.prepared = true; + +await next(); + +// job read bag, put more in +workItem.state.testsWritten = 42; + +// outer coat read what job did +console.log(workItem.state.testsWritten); +``` + +Caveman rules for bag: + +1. **Put in bag** if next guy need it. +2. **Read from bag** if you need it. +3. **Don't eat other tribe's bag** (wrong work item). +4. **metadata** for labels. **state** for actual stuff. + +--- + +## Good caveman vs bad caveman + +### Bad caveman + +``` +make new script for every tiny difference +copy paste write-tests 47 time +no coat. spaghetti wires everywhere +forget call next(). job never run. wonder why quiet. +call next() but no await. job still running. coat already leave. chaos. +``` + +### Good caveman + +``` +one script per real job +coat for "how we run this job here" +flow pick coats at schedule time +await next() when you care about finish +throw rock when check fail +``` + +--- + +## FAQ (frequently asked grunts) + +**Q: `flow` empty?** +A: No coat. Just job. `myScriptFn(item)`. Simple. Happy. + +**Q: Coat name in `flow` but not in coat list?** +A: Runner confused. Error. Fix list. + +**Q: Job name in `kind` but not in job list?** +A: Same. Error. Fix list. + +**Q: Put job name in `flow`?** +A: **NO.** `flow` = coat names only. `kind` = job name. Don't mix. Bad hunt. + +**Q: Who run first, `flow[0]` or `flow[1]`?** +A: `flow[0]` **outside**. See world first. Wrap everyone inside. + +**Q: Can coat skip job?** +A: Yes. Don't push `next`. Job never happen. Boss power. + +**Q: Can coat run job three time?** +A: Yes. Push `next` in loop. Retry coat. See cookbook. + +**Q: Can job be coat?** +A: Different list. Different shape. Job no get `next` button. Don't confuse tribe. + +**Q: Why not one big list?** +A: Job = do work. Coat = boss work. Different job. Two list = clear. Caveman like clear. + +--- + +## Runner walk (step by step) + +1. Runner get work note. +2. Look up `kind` in **job list**. Find meat function. +3. Look up each name in `flow` in **coat list**. Outermost first. +4. Wrap: outer coat gets `next` that runs rest. +5. Outermost coat start. +6. Eventually `next` chain hit meat job. +7. Job finish. Coats unwrap. Promise settle. +8. Tribe eat. + +``` +NOTE ARRIVE + → find job + → find coats + → nest like onion + → outer coat START + → ... next ... next ... JOB + → bubble back out + → DONE +``` + +--- + +## One grunt summary + +**Script do work. Wrapper boss work — ready, go, check, retry — with `next` button.** + +## Two grunt summary + +**Two list. One note. Coats outside. Job inside. Shared bag. Push `next` or don't. Caveman strong.** + +## Three grunt summary (bonus) + +``` +scripts = DO +wrappers = BOSS DO +flow = WHICH BOSS +kind = WHAT DO +state = SHARED BAG +next = GO BUTTON +``` + +_thunk rock. document done._ diff --git a/orchestrator-v2/docs/temporal/script-stack-eli5.md b/orchestrator-v2/docs/temporal/script-stack-eli5.md new file mode 100644 index 0000000..d7b7f16 --- /dev/null +++ b/orchestrator-v2/docs/temporal/script-stack-eli5.md @@ -0,0 +1,99 @@ +# Script Stack (ELI5) + +A plain-language version of [script-stack.md](./script-stack.md). + +## The problem + +Right now, figuring out _which code runs_ for a piece of work is messy. Every +layer of the system has its own special rules. Adding a new behavior means +wiring up more one-off plumbing. + +## The idea + +Give the runner two phone books: + +1. **Scripts** — “when someone asks for _write-tests_, run this function.” +2. **Wrappers** — “when someone asks for _typescript-tests_, use this + decorator.” + +A **work item** is just a note that says what to do: + +- `kind` — the main job (looked up in **scripts**) +- `flow` — optional extra layers around that job (looked up in **wrappers**) +- `state` — a shared backpack everyone can read and write + +## Scripts vs wrappers + +A **script** does the job: + +> “Here’s the work. Go do it.” + +A **wrapper** sits around the job like a coat: + +> “Let me get you ready… okay, _you_ go now… okay, let me check your work.” + +The wrapper doesn’t replace the job. It gets a button called **`next`**. Press +`next` and the stuff _inside_ runs — more wrappers, then finally the main +script. + +``` +wrapper1 says: + "before you go in..." + wrapper2 says: + "hold on, one more thing..." + write-tests actually runs + "okay, looks good from here" + "all done, I checked everything" +``` + +## Why `next` matters + +`next` is “continue to the rest of the stack.” + +The wrapper is in charge: + +- **Never call `next`** — skip the inner work entirely +- **Call `next` once** — normal: do setup, run job, do cleanup +- **Call `next` many times** — retry if something fails + +That’s the whole trick. One simple rule, lots of flexibility. + +## Retry example + +```typescript +const retry = async (workItem, next) => { + let tries = 0; + while (true) { + try { + return await next(); // try the inner work + } catch (e) { + if (++tries >= 3) throw e; // gave up after 3 tries + } + } +}; +``` + +In English: “Try the job. If it blows up, try again — up to 3 times.” + +## Write-tests example + +`write-tests` knows how to write good tests in general. + +A workflow wants TypeScript tests specifically. Instead of forking +`write-tests` into `write-typescript-tests-with-vitest-and-check`, it says: + +- **kind:** `write-tests` (same script as always) +- **flow:** `typescript-tests` (a wrapper that adds TS-specific behavior) + +The wrapper: + +1. Prepares the context (vitest, vitest-gwt, etc.) +2. Calls `next()` so `write-tests` runs +3. Checks the result (run vitest, expect failure, etc.) + +Same core script. Different “coat” depending on what the workflow needs. + +## One sentence summary + +**Scripts do the work; wrappers decide how the work gets prepared, run, retried, +and checked — by wrapping `next`.** diff --git a/orchestrator-v2/docs/temporal/script-stack.md b/orchestrator-v2/docs/temporal/script-stack.md new file mode 100644 index 0000000..e1a771d --- /dev/null +++ b/orchestrator-v2/docs/temporal/script-stack.md @@ -0,0 +1,144 @@ +# Script Stack + +## Problem + +The runner currently finds work item scripts via a complicated lookup. Each +agent layer feels disconnected and bespoke. This makes extending them very +difficult. + +## Proposed Solution + +The script stack. The runner has a single source of work item mapped scripts. + +We have two dictionaries: + +1. **Scripts** — `scriptKind → ScriptFn`. The runner does a simple lookup to + find the function to execute. The scriptKind is a first-class field on the + work item. +2. **Decorators** — `decoratorName → DecoratorFn`. A decorator wraps inner + execution via a `next` callback. Both scripts and decorators receive a + shared `ScriptContext` (`cwd`, `data`, `setState`). + +The work item has a `flow` array naming decorators (outermost first) to apply +around the work item's `kind`. + +**Conventions** are runner-level decorators applied to every work item before +`flow`. The default convention is `failOnError`, which catches thrown errors +and maps them to `{ outcome: "failed" }`. + +```typescript +type ScriptContext = { + cwd: string; + data: DataRegistry>; + setState: (state: Record) => Promise; +}; + +type ScriptFn = (workItem: WorkItem, ctx: ScriptContext) => Promise; + +type DecoratorFn = ( + workItem: WorkItem, + ctx: ScriptContext, + next: () => Promise, +) => Promise; + +type WorkItem = { + workItemId: string; + kind: string; // scriptKind used to lookup the core script + flow: string[]; // decorator names, outermost first + state: Record; + metadata: Record; +}; + +// Runner configuration +type ScriptStack = { + scripts: Record; + decorators: Record; + conventions: string[]; // runner-level decorator names, outermost first +}; +``` + +When a runner receives the work item, it resolves the core script from `kind`, +conventions from the runner config, and decorators from `flow`, then nests them: + +``` +conventions → flow → script +``` + +```typescript +// given +const item = { + kind: "myScript", + flow: ["decorator1", "decorator2"], +}; + +// runner.conventions = ["failOnError"] + +// equivalent to: +// failOnError(item, ctx, () => +// decorator1(item, ctx, () => +// decorator2(item, ctx, () => +// myScriptFn(item, ctx)))) +``` + +A decorator must call `next()` to continue the child flow. It may call `next()` +zero times (short-circuit), once (typical), or many times (retry). It may run +logic before `next()` (prepare), after `next()` (check), or around both. + +```typescript +const retry: DecoratorFn = async (workItem, ctx, next) => { + let i = 0; + while (true) { + try { + return await next(); + } catch (e) { + if (++i >= 3) throw e; + } + } +}; + +const failOnError: DecoratorFn = async (workItem, ctx, next) => { + try { + return await next(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { outcome: "failed", message }; + } +}; +``` + +Core scripts must satisfy `ScriptFn`; decorators must satisfy `DecoratorFn`. If a +script needs to pass data to the next layer, it should modify the work item's +`state` or call `ctx.setState`. + +## Example + +A level 3 task agent exists called "write-tests". This task agent knows what +good tests look like. + +A level 4 workflow agent wants to write some typescript tests. It schedules a +work item with `kind: "write-tests"` and a wrapper that prepares the test +context before execution and validates the output afterward — without needing a +standalone follow-up script. + +```typescript +const typescriptTests: DecoratorFn = async (workItem, ctx, next) => { + await prepareTsContext(workItem, ctx); // modify instructions for vitest-gwt + await next(); // write-tests runs here + await validateTsTests(workItem, ctx); // run vitest, expect tests to fail +}; + +const item = { + kind: "write-tests", + flow: ["typescript-tests"], +}; + +const stack = { + scripts: { + "write-tests": writeTestsFn, + }, + decorators: { + "typescript-tests": typescriptTests, + }, + conventions: ["failOnError"], +}; +``` diff --git a/orchestrator-v2/examples/lvl3/doSomething.ts b/orchestrator-v2/examples/lvl3/doSomething.ts index 4f6d570..62b2701 100644 --- a/orchestrator-v2/examples/lvl3/doSomething.ts +++ b/orchestrator-v2/examples/lvl3/doSomething.ts @@ -1,6 +1,6 @@ import type { ScriptFn } from "@bifrost-ai/runner"; -export const doSomething: ScriptFn = ({ cwd, workItem }) => { - console.log(`the cwd is ${cwd} for task ${JSON.stringify(workItem.state)}`); +export const doSomething: ScriptFn = async (workItem, ctx) => { + console.log(`the cwd is ${ctx.cwd} for task ${JSON.stringify(workItem.state)}`); return { outcome: "completed" }; }; diff --git a/orchestrator-v2/examples/lvl3/runner.ts b/orchestrator-v2/examples/lvl3/runner.ts index 2acac9f..196df45 100644 --- a/orchestrator-v2/examples/lvl3/runner.ts +++ b/orchestrator-v2/examples/lvl3/runner.ts @@ -15,4 +15,4 @@ export const runner = new Runner({ data: createDataRegistry(taskAgentDataGuards) runner.registerEngine("cursor", new CursorEngine()); runner.registerTaskAgent("cowsay", await loadAgent(cowsayAgentPath)); -runner.registerScriptAgent("doSomething", doSomething); +runner.registerScript("doSomething", doSomething); diff --git a/orchestrator-v2/packages/agent-3-task/src/augment.spec.ts b/orchestrator-v2/packages/agent-3-task/src/augment.spec.ts index 8602f94..7ff6db9 100644 --- a/orchestrator-v2/packages/agent-3-task/src/augment.spec.ts +++ b/orchestrator-v2/packages/agent-3-task/src/augment.spec.ts @@ -32,5 +32,5 @@ function a_task_agent_is_registered(this: Context) { } function the_handler_is_available_by_dispatch_name(this: Context) { - expect(this.runner.hasWorkItemHandler("task", "greeter")).toBe(true); + expect(this.runner.hasScript("greeter")).toBe(true); } diff --git a/orchestrator-v2/packages/agent-3-task/src/augment.ts b/orchestrator-v2/packages/agent-3-task/src/augment.ts index 75022cc..5ccc9f5 100644 --- a/orchestrator-v2/packages/agent-3-task/src/augment.ts +++ b/orchestrator-v2/packages/agent-3-task/src/augment.ts @@ -9,6 +9,7 @@ declare module "@bifrost-ai/runner" { interface Runner { registerTaskAgent(name: string, agent: AgentDefinition): void; registerEngine(name: string, engine: Engine): void; + registerScript(kind: string, fn: import("@bifrost-ai/interfaces-work").ScriptFn): void; } } @@ -18,7 +19,7 @@ Runner.prototype.registerTaskAgent = function registerTaskAgent( agent: AgentDefinition, ): void { this.data.get(AGENT_DEFINITION_DATA_TYPE).register(name, agent); - this.registerWorkItemHandler(createTaskAgent(agent, name)); + this.registerScript(name, createTaskAgent(agent, name)); }; Runner.prototype.registerEngine = function registerEngine( diff --git a/orchestrator-v2/packages/agent-3-task/src/create-task-agent.ts b/orchestrator-v2/packages/agent-3-task/src/create-task-agent.ts index 5246edc..6cbaa03 100644 --- a/orchestrator-v2/packages/agent-3-task/src/create-task-agent.ts +++ b/orchestrator-v2/packages/agent-3-task/src/create-task-agent.ts @@ -1,19 +1,17 @@ import type { AgentDefinition } from "@bifrost-ai/engine"; -import type { WorkItemExecutionContext, WorkItemHandler } from "@bifrost-ai/interfaces-work"; +import type { DataRegistry, ScriptFn } from "@bifrost-ai/interfaces-work"; import { runTaskAgent } from "./run-task-agent.js"; import type { TaskAgentDataSchema } from "./types.js"; -export function createTaskAgent(agent: AgentDefinition, name: string): WorkItemHandler { - return { - kind: "task", - name, - async run(workItem, ctx) { - return runTaskAgent( - workItem, - ctx as WorkItemExecutionContext>, - agent, - ); - }, - }; +export function createTaskAgent(agent: AgentDefinition, _name: string): ScriptFn { + return async (workItem, ctx) => + runTaskAgent( + workItem, + { + data: ctx.data as DataRegistry>, + setState: ctx.setState, + }, + agent, + ); } diff --git a/orchestrator-v2/packages/agent-3-task/src/run-task-agent.spec.ts b/orchestrator-v2/packages/agent-3-task/src/run-task-agent.spec.ts index 5db0c04..15a6016 100644 --- a/orchestrator-v2/packages/agent-3-task/src/run-task-agent.spec.ts +++ b/orchestrator-v2/packages/agent-3-task/src/run-task-agent.spec.ts @@ -78,8 +78,8 @@ function makeExecutionFixture( const workItem: WorkItem = { workItemId: "work-item-123", - kind: "task", - name, + kind: name, + flow: [], metadata: { priority: "high" }, state: liveState, }; diff --git a/orchestrator-v2/packages/agent-3-task/src/run-task-agent.ts b/orchestrator-v2/packages/agent-3-task/src/run-task-agent.ts index 58e5de3..01841fc 100644 --- a/orchestrator-v2/packages/agent-3-task/src/run-task-agent.ts +++ b/orchestrator-v2/packages/agent-3-task/src/run-task-agent.ts @@ -1,9 +1,5 @@ import type { AgentDefinition } from "@bifrost-ai/engine"; -import type { - WorkItem, - WorkItemExecutionContext, - WorkItemResult, -} from "@bifrost-ai/interfaces-work"; +import type { DataRegistry, WorkItem, WorkItemResult } from "@bifrost-ai/interfaces-work"; import { ENGINE_DATA_TYPE, @@ -12,9 +8,14 @@ import { type TaskAgentDataSchema, } from "./types.js"; +type TaskAgentContext = { + data: DataRegistry>; + setState: (state: Record) => Promise; +}; + export async function runTaskAgent( workItem: WorkItem, - ctx: WorkItemExecutionContext>, + ctx: TaskAgentContext, agent: AgentDefinition, ): Promise { const parsed = parseTaskAgentState(workItem.state); diff --git a/orchestrator-v2/packages/interfaces-work/src/index.ts b/orchestrator-v2/packages/interfaces-work/src/index.ts index 5c8a778..942aa09 100644 --- a/orchestrator-v2/packages/interfaces-work/src/index.ts +++ b/orchestrator-v2/packages/interfaces-work/src/index.ts @@ -1,8 +1,12 @@ export type { CreateDraftWorkItemInput, DataRegistry, + DecoratorFn, ExecutionStats, Registry, + ScriptContext, + ScriptFn, + ScriptStack, WorkItem, WorkItemDependency, WorkItemExecutionContext, @@ -13,6 +17,7 @@ export type { } from "./types.js"; export { isWorkItemHandler, + isWorkItemResult, missingWorkItemFields, missingWorkItemFieldsMessage, isWorkItem as validateWorkItem, diff --git a/orchestrator-v2/packages/interfaces-work/src/types.ts b/orchestrator-v2/packages/interfaces-work/src/types.ts index 2f17beb..d923ac5 100644 --- a/orchestrator-v2/packages/interfaces-work/src/types.ts +++ b/orchestrator-v2/packages/interfaces-work/src/types.ts @@ -1,7 +1,7 @@ export type WorkItem = { workItemId: string; kind: string; - name: string; + flow: string[]; state: Record; readonly metadata: Record; }; @@ -13,7 +13,7 @@ export type WorkItemDependency = { export type CreateDraftWorkItemInput = { kind: string; - name: string; + flow?: string[]; state?: Record; metadata?: Record; }; @@ -30,7 +30,29 @@ export type WorkItemSource = { getDependencies(workItemId: string): Promise; }; -const REQUIRED_WORK_ITEM_FIELDS = ["workItemId", "kind", "name"] as const; +export type ScriptContext = Record> = { + cwd: string; + data: DataRegistry; + setState: (state: Record) => Promise; +}; + +export type ScriptFn = Record> = ( + workItem: WorkItem, + ctx: ScriptContext, +) => Promise; + +export type DecoratorFn = Record> = ( + workItem: WorkItem, + ctx: ScriptContext, + next: () => Promise, +) => Promise; + +export type ScriptStack = Record> = { + scripts: Record>; + decorators: Record>; +}; + +const REQUIRED_WORK_ITEM_FIELDS = ["workItemId", "kind", "flow"] as const; export function isWorkItem(value: unknown): value is WorkItem { if (value === null || typeof value !== "object") { @@ -43,8 +65,8 @@ export function isWorkItem(value: unknown): value is WorkItem { record.workItemId.length === 0 || typeof record.kind !== "string" || record.kind.length === 0 || - typeof record.name !== "string" || - record.name.length === 0 || + !Array.isArray(record.flow) || + !record.flow.every((entry) => typeof entry === "string" && entry.length > 0) || record.state === null || typeof record.state !== "object" || record.metadata === null || @@ -80,12 +102,18 @@ export function missingWorkItemFields(value: unknown): string[] { if (typeof record.workItemId === "string" && record.workItemId.length === 0) { missing.push("workItemId"); } - if (typeof record.name === "string" && record.name.length === 0) { - missing.push("name"); - } if (typeof record.kind === "string" && record.kind.length === 0) { missing.push("kind"); } + if (record.flow !== undefined && !Array.isArray(record.flow)) { + missing.push("flow"); + } + if ( + Array.isArray(record.flow) && + !record.flow.every((entry) => typeof entry === "string" && entry.length > 0) + ) { + missing.push("flow"); + } return [...new Set(missing)]; } @@ -151,3 +179,14 @@ export function isWorkItemHandler(value: unknown): value is WorkItemHandler { typeof record.run === "function" ); } + +export function isWorkItemResult(value: unknown): value is WorkItemResult { + if (value === null || typeof value !== "object") { + return false; + } + + const record = value as Partial; + return ( + record.outcome === "completed" || record.outcome === "failed" || record.outcome === "paused" + ); +} diff --git a/orchestrator-v2/packages/orchestrator/src/test-helpers.ts b/orchestrator-v2/packages/orchestrator/src/test-helpers.ts index 1bbebdb..71ccbba 100644 --- a/orchestrator-v2/packages/orchestrator/src/test-helpers.ts +++ b/orchestrator-v2/packages/orchestrator/src/test-helpers.ts @@ -292,8 +292,8 @@ function waitForRpcResponse(runner: RunnerPeer, id: string): Promise { export function sampleWorkItem(workItemId: string): WorkItem { return { workItemId, - kind: "script", - name: "echo", + kind: "echo", + flow: [], state: {}, metadata: {}, }; diff --git a/orchestrator-v2/packages/runner/README.md b/orchestrator-v2/packages/runner/README.md index 732651f..ed9ca00 100644 --- a/orchestrator-v2/packages/runner/README.md +++ b/orchestrator-v2/packages/runner/README.md @@ -2,95 +2,68 @@ Remote script runner for Bifrost orchestrator v2. -Design background: [docs/runner.md](../../docs/runner.md) · Issue [#36](https://github.com/devzeebo/bifrost/issues/36) +Design background: [docs/runner.md](../../docs/runner.md) · [docs/temporal/script-stack.md](../../docs/temporal/script-stack.md) ## Purpose -Runners dial the orchestrator, execute registered agents locally, and report signed outcomes. This package is the primary entry point for building a runner process. +Runners dial the orchestrator, execute registered scripts locally through a composable decorator stack, and report signed outcomes. -## Public API +## Script stack -### `Runner` +Execution composes three layers, outermost first: -```typescript -const data = createDataRegistry({ engine: isEngine }); -const runner = new Runner({ data }); - -data.get("engine").register("claude", claudeEngine); -runner.registerWorkItemHandler(echo); -await runner.start(); -runner.close(); -runner.connection: RunnerPeer; // after start() -runner.data: DataRegistry; ``` +conventions → flow decorators → script +``` + +| Layer | Declared on | API | +| -------------- | ----------- | ----------------------------------------------------- | +| **Script** | Runner | `registerScript(kind, fn)` | +| **Decorator** | Runner | `registerDecorator(name, fn)` | +| **Convention** | Runner | `addConvention(name)` — defaults to `["failOnError"]` | +| **Flow** | Work item | `workItem.flow` — decorator names, outermost first | -### Config-driven usage +Both conventions and flow use the same decorator registry. The built-in `failOnError` convention catches thrown errors and maps them to `{ outcome: "failed" }`. -With `runner.yaml` present, register data and agents before starting: +## Public API + +### `Runner` ```typescript -const runner = new Runner({ data }); -runner.registerWorkItemHandler(echo); +import type { ScriptFn } from "@bifrost-ai/interfaces-work"; +import { Runner, createDataRegistry } from "@bifrost-ai/runner"; + +const echo: ScriptFn = async (workItem, ctx) => { + await ctx.setState({ echoed: workItem.metadata.message }); + return { outcome: "completed" }; +}; + +const runner = new Runner({ data: createDataRegistry(guards) }); +runner.registerScript("echo", echo); +runner.addConvention("log"); // optional runner-wide decorator await runner.start(); ``` ### Lower-level exports -- `createDataRegistry(guards)` — create a typed data registry up front -- `createScriptAgent(fn, name)` — wrap a function as a `kind: "script"` handler -- `ScriptFn` — function signature accepted by `registerScriptAgent` -- `loadRunnerConfig(configPath)` — parse and validate YAML config -- `resolveRunnerOptions(options)` — merge config file + overrides -- `executeWorkItem(handler, ctx)` — run a handler in-process -- `createRpcWorkItemExecutionContext(workItem, rpc, data, handlers)` — build RPC-backed `WorkItemExecutionContext` -- `createRpcClient(peer)` — RPC helper for orchestrator callbacks -- `Registry` — generic name-keyed registry backing store - -## Registry model - -| Kind | Setup | Dispatch | Handler access | -| ----- | ---------------------------------- | --------------------------------- | ------------------------------ | -| Data | `createDataRegistry(guards)` | Never | `ctx.data.get(type).get(name)` | -| Agent | `registerWorkItemHandler(handler)` | `workItem.kind` + `workItem.name` | `ctx.handlers.get(kind, name)` | +- `registerScriptAgent(runner, name, fn)` — adapt legacy `{ workItem, cwd, setState }` scripts +- `composeStack`, `executeScriptStack`, `resolveStack` — in-process stack execution +- `createScriptContext` — build RPC-backed `ScriptContext` for a dispatch +- `failOnError`, `FAIL_ON_ERROR_DECORATOR` — built-in error-handling convention +- `createDataRegistry(guards)` — typed data registry +- `Registry` — generic name-keyed registry ## Config schema -| Field | Required | Description | -| ---------------------------------------------- | -------- | -------------------------------- | -| `orchestrator.url` | yes | WebSocket URL (`ws://host:port`) | -| `orchestrator.keyId` | yes | Orchestrator signing key id | -| `orchestrator.publicKeyPem` or `publicKeyPath` | yes | Trusted orchestrator public key | -| `identity.keyId` | yes | Runner signing key id | -| `identity.privateKeyPem` or `privateKeyPath` | yes | Runner private key | -| `identity.publicKeyPem` or `publicKeyPath` | yes | Runner public key | -| `heartbeatIntervalMs` | no | Default `10000` | - -PEM paths resolve relative to the config file directory. +See [docs/runner.md](../../docs/runner.md) for `runner.yaml` fields and trust model. ## Module map -| Module | Responsibility | -| -------------------------------- | ---------------------------------------------- | -| `runner.ts` | `Runner` class lifecycle | -| `data-registry.ts` | `createDataRegistry`, guarded sub-registries | -| `config-loader.ts` | YAML discovery, PEM loading, option resolution | -| `registry.ts` | Generic name-keyed registry | -| `dispatch-handler.ts` | Handle `dispatch` RPC → execute → terminal RPC | -| `work-item-execution-context.ts` | RPC-backed `WorkItemExecutionContext` | -| `rpc-client.ts` | Signed RPC request/response helper | -| `execute-work-item.ts` | In-process handler execution | -| `heartbeat.ts` | Periodic signed heartbeats | - -## Error cases - -- `Runner already started` — second `start()` call -- `Runner not started` — accessing `connection` before `start()` -- `Already registered: {name}` — duplicate registration in a registry -- `Invalid data registration: {name}` — item failed the type guard for that data type -- Config validation errors — missing url, keys, or invalid PEM paths (fail before dial) - -## Trust model - -Inbound: only frames signed by `orchestrator.keyId` reach handlers (protocol layer). - -Outbound: all runner frames signed with runner identity; orchestrator must list runner pubkey in `authorizedRunners`. +| Module | Responsibility | +| ------------------------------ | ---------------------------------------------------- | +| `runner.ts` | `Runner` class lifecycle | +| `script-stack.ts` | Compose and execute script + decorator stacks | +| `script-context.ts` | Per-dispatch `ScriptContext` with RPC `setState` | +| `conventions/fail-on-error.ts` | Built-in `failOnError` convention | +| `dispatch-handler.ts` | Handle `dispatch` RPC → execute stack → terminal RPC | +| `data-registry.ts` | Typed sub-registries for engines, agent defs, etc. | diff --git a/orchestrator-v2/packages/runner/src/conventions/fail-on-error.ts b/orchestrator-v2/packages/runner/src/conventions/fail-on-error.ts new file mode 100644 index 0000000..3f559ce --- /dev/null +++ b/orchestrator-v2/packages/runner/src/conventions/fail-on-error.ts @@ -0,0 +1,12 @@ +import type { DecoratorFn } from "@bifrost-ai/interfaces-work"; + +export const FAIL_ON_ERROR_DECORATOR = "failOnError"; + +export const failOnError: DecoratorFn = async (_workItem, _ctx, next) => { + try { + return await next(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { outcome: "failed", message }; + } +}; diff --git a/orchestrator-v2/packages/runner/src/dispatch-handler.ts b/orchestrator-v2/packages/runner/src/dispatch-handler.ts index fe6d791..9c0d1d1 100644 --- a/orchestrator-v2/packages/runner/src/dispatch-handler.ts +++ b/orchestrator-v2/packages/runner/src/dispatch-handler.ts @@ -3,33 +3,39 @@ import { missingWorkItemFieldsMessage, validateWorkItem, } from "@bifrost-ai/interfaces-work"; -import type { DataRegistry, WorkItemHandler } from "@bifrost-ai/interfaces-work"; +import type { DataRegistry, DecoratorFn, ScriptFn } from "@bifrost-ai/interfaces-work"; import type { FramePayload, RunnerPeer } from "@bifrost-ai/protocol"; -import { executeWorkItem } from "./execute-work-item.js"; import { sendRpcResponse, type RpcClient } from "./rpc-client.js"; -import { createRpcWorkItemExecutionContext } from "./work-item-execution-context.js"; +import { createScriptContext } from "./script-context.js"; +import { executeScriptStack, resolveStack } from "./script-stack.js"; import type { Registry } from "./registry.js"; -export function registerDispatchHandler( +export type DispatchScriptStack> = { + scripts: Registry>; + decorators: Registry>; + conventions: readonly string[]; + data: DataRegistry; + rpc: RpcClient; +}; + +export function registerDispatchHandler>( peer: RunnerPeer, - handlers: Map>, - data: DataRegistry>, - rpc: RpcClient, + stack: DispatchScriptStack, ): () => void { return peer.subscribe( (payload) => payload.kind === "rpc.request" && payload.method === "dispatch", (payload) => { - void handleDispatch(peer, handlers, data, rpc, payload).catch(() => undefined); + void handleDispatch(peer, stack, payload).catch((error) => { + console.error("Unhandled dispatch error:", error); + }); }, ); } -async function handleDispatch( +async function handleDispatch>( peer: RunnerPeer, - handlers: Map>, - data: DataRegistry>, - rpc: RpcClient, + stack: DispatchScriptStack, payload: FramePayload, ): Promise { if (payload.kind !== "rpc.request") { @@ -46,37 +52,54 @@ async function handleDispatch( return; } - const handler = handlers.get(workItem.kind)?.get(workItem.name); - if (handler === undefined) { + let resolved; + try { + resolved = resolveStack(workItem, stack.scripts, stack.decorators, stack.conventions); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); sendRpcResponse(peer, payload.id, { accepted: false, - reason: `Unknown work item handler: ${workItem.kind}/${workItem.name}`, + reason: message, }); return; } sendRpcResponse(peer, payload.id, { accepted: true }); - const { workItem: liveWorkItem, ctx } = createRpcWorkItemExecutionContext( - workItem, - rpc, - data, - handlers, - ); - const result = await executeWorkItem(handler, liveWorkItem, ctx); + try { + const { workItem: liveWorkItem, ctx } = createScriptContext(workItem, stack.rpc, stack.data); + const result = await executeScriptStack(liveWorkItem, ctx, resolved); - switch (result.outcome) { - case "completed": - await rpc.call("workItem.complete", { workItemId: workItem.workItemId }); - break; - case "failed": - await rpc.call("workItem.fail", { + switch (result.outcome) { + case "completed": + await stack.rpc.call("workItem.complete", { workItemId: workItem.workItemId }); + break; + case "failed": + await stack.rpc.call("workItem.fail", { + workItemId: workItem.workItemId, + message: result.message ?? "failed", + }); + break; + case "paused": + await stack.rpc.call("workItem.pause", { workItemId: workItem.workItemId }); + break; + default: + await stack.rpc.call("workItem.fail", { + workItemId: workItem.workItemId, + message: `Unknown outcome: ${String(result.outcome)}`, + }); + break; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error("Dispatch execution failed:", error); + try { + await stack.rpc.call("workItem.fail", { workItemId: workItem.workItemId, - message: result.message ?? "failed", + message, }); - break; - case "paused": - await rpc.call("workItem.pause", { workItemId: workItem.workItemId }); - break; + } catch (failError) { + console.error("Failed to report work item failure:", failError); + } } } diff --git a/orchestrator-v2/packages/runner/src/execute-work-item.ts b/orchestrator-v2/packages/runner/src/execute-work-item.ts deleted file mode 100644 index 95a354c..0000000 --- a/orchestrator-v2/packages/runner/src/execute-work-item.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { - WorkItem, - WorkItemExecutionContext, - WorkItemHandler, - WorkItemResult, -} from "@bifrost-ai/interfaces-work"; - -export async function executeWorkItem( - handler: WorkItemHandler, - workItem: WorkItem, - ctx: WorkItemExecutionContext, -): Promise { - try { - return await handler.run(workItem, ctx); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { outcome: "failed", message }; - } -} diff --git a/orchestrator-v2/packages/runner/src/index.ts b/orchestrator-v2/packages/runner/src/index.ts index b44fba0..597ae18 100644 --- a/orchestrator-v2/packages/runner/src/index.ts +++ b/orchestrator-v2/packages/runner/src/index.ts @@ -1,12 +1,18 @@ export { Runner } from "./runner.js"; export { Registry } from "./registry.js"; export { createDataRegistry } from "./data-registry.js"; -export { createScriptAgent, type ScriptFn } from "./script-agent.js"; +export { registerScriptAgent, type LegacyScriptFn, type ScriptFn } from "./script-agent.js"; export { discoverConfigPath, loadRunnerConfig, resolveRunnerOptions } from "./config-loader.js"; -export { executeWorkItem } from "./execute-work-item.js"; -export { createRpcWorkItemExecutionContext } from "./work-item-execution-context.js"; +export { createScriptContext } from "./script-context.js"; +export { + composeStack, + executeScriptStack, + normalizeScriptResult, + resolveStack, +} from "./script-stack.js"; export { createRpcClient } from "./rpc-client.js"; export type { RpcClient } from "./rpc-client.js"; +export { FAIL_ON_ERROR_DECORATOR, failOnError } from "./conventions/fail-on-error.js"; export type { IdentityConfig, OrchestratorPublicKeyConfig, diff --git a/orchestrator-v2/packages/runner/src/runner.spec.ts b/orchestrator-v2/packages/runner/src/runner.spec.ts index 915483c..08fd6c6 100644 --- a/orchestrator-v2/packages/runner/src/runner.spec.ts +++ b/orchestrator-v2/packages/runner/src/runner.spec.ts @@ -2,7 +2,7 @@ import { mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { WorkItemHandler } from "@bifrost-ai/interfaces-work"; +import type { ScriptFn } from "@bifrost-ai/interfaces-work"; import type { PeerIdentity } from "@bifrost-ai/protocol"; import { describe, expect } from "vite-plus/test"; import test, { withAspect } from "vitest-gwt"; @@ -32,31 +32,17 @@ type Context = { duplicateError: Error | null; }; -const echoHandler: WorkItemHandler = { - kind: "script", - name: "echo", - async run(workItem, ctx) { - const message = workItem.metadata.message as string; - await ctx.setState({ echoed: message }); - return { outcome: "completed" }; - }, +const echoScript: ScriptFn = async (workItem, ctx) => { + const message = workItem.metadata.message as string; + await ctx.setState({ echoed: message }); + return { outcome: "completed" }; }; -const failHandler: WorkItemHandler = { - kind: "script", - name: "fail", - async run() { - throw new Error("boom"); - }, +const failScript: ScriptFn = async () => { + throw new Error("boom"); }; -const pauseHandler: WorkItemHandler = { - kind: "script", - name: "pause", - async run() { - return { outcome: "paused" }; - }, -}; +const pauseScript: ScriptFn = async () => ({ outcome: "paused" }); describe("runner", () => { withAspect(setup_identities, teardown_runner); @@ -78,7 +64,7 @@ describe("runner", () => { }, }); - test("fails when handler throws", { + test("fails when script throws", { given: { work_item_source_with_fail, orchestrator_running, @@ -94,7 +80,7 @@ describe("runner", () => { }, }); - test("pauses when handler returns paused", { + test("pauses when script returns paused", { given: { work_item_source_with_pause, orchestrator_running, @@ -110,19 +96,19 @@ describe("runner", () => { }, }); - test("registerWorkItemHandler throws on duplicate name within a kind", { + test("registerScript throws on duplicate kind", { given: { empty_runner, }, when: { - registering_duplicate_handler, + registering_duplicate_script, }, then: { duplicate_error_thrown, }, }); - test("handler enrollment via registerWorkItemHandler", { + test("script enrollment via registerScript", { given: { work_item_source_with_echo, orchestrator_running, @@ -156,8 +142,8 @@ function work_item_source_with_echo(this: Context) { this.workItemSource = createMemoryWorkItemSource([ { workItemId: "work-item-1", - kind: "script", - name: "echo", + kind: "echo", + flow: [], state: {}, metadata: { message: "hello" }, }, @@ -168,8 +154,8 @@ function work_item_source_with_fail(this: Context) { this.workItemSource = createMemoryWorkItemSource([ { workItemId: "work-item-fail", - kind: "script", - name: "fail", + kind: "fail", + flow: [], state: {}, metadata: {}, }, @@ -180,8 +166,8 @@ function work_item_source_with_pause(this: Context) { this.workItemSource = createMemoryWorkItemSource([ { workItemId: "work-item-pause", - kind: "script", - name: "pause", + kind: "pause", + flow: [], state: {}, metadata: {}, }, @@ -223,17 +209,17 @@ async function runner_config_written(this: Context) { function runner_with_echo_registered(this: Context) { this.runner = new Runner({ configPath: this.configPath }); - this.runner.registerWorkItemHandler(echoHandler); + this.runner.registerScript("echo", echoScript); } function runner_with_fail_registered(this: Context) { this.runner = new Runner({ configPath: this.configPath }); - this.runner.registerWorkItemHandler(failHandler); + this.runner.registerScript("fail", failScript); } function runner_with_pause_registered(this: Context) { this.runner = new Runner({ configPath: this.configPath }); - this.runner.registerWorkItemHandler(pauseHandler); + this.runner.registerScript("pause", pauseScript); } function empty_runner(this: Context) { @@ -242,11 +228,11 @@ function empty_runner(this: Context) { function runner_with_plugin_enrollment(this: Context) { this.runner = new Runner({ configPath: this.configPath }); - enrollEchoHandler(this.runner); + enrollEchoScript(this.runner); } -function enrollEchoHandler(runner: Runner): void { - runner.registerWorkItemHandler(echoHandler); +function enrollEchoScript(runner: Runner): void { + runner.registerScript("echo", echoScript); } async function runner_started(this: Context) { @@ -254,10 +240,10 @@ async function runner_started(this: Context) { await delay(50); } -async function registering_duplicate_handler(this: Context) { - this.runner.registerWorkItemHandler(echoHandler); +async function registering_duplicate_script(this: Context) { + this.runner.registerScript("echo", echoScript); try { - this.runner.registerWorkItemHandler(echoHandler); + this.runner.registerScript("echo", echoScript); this.duplicateError = null; } catch (error) { this.duplicateError = error as Error; diff --git a/orchestrator-v2/packages/runner/src/runner.ts b/orchestrator-v2/packages/runner/src/runner.ts index c406c9e..426d49d 100644 --- a/orchestrator-v2/packages/runner/src/runner.ts +++ b/orchestrator-v2/packages/runner/src/runner.ts @@ -1,19 +1,29 @@ -import type { DataRegistry, WorkItemHandler } from "@bifrost-ai/interfaces-work"; +import type { + DataRegistry, + DecoratorFn, + ScriptFn, + WorkItemResult, +} from "@bifrost-ai/interfaces-work"; import { createRunnerPeer, type RunnerPeer } from "@bifrost-ai/protocol"; import { resolveRunnerOptions } from "./config-loader.js"; +import { FAIL_ON_ERROR_DECORATOR, failOnError } from "./conventions/fail-on-error.js"; import { registerDispatchHandler } from "./dispatch-handler.js"; import { startHeartbeat, type HeartbeatHandle } from "./heartbeat.js"; import { createRpcClient } from "./rpc-client.js"; import { createDataRegistry } from "./data-registry.js"; import { Registry } from "./registry.js"; -import { createScriptAgent, type ScriptFn } from "./script-agent.js"; +import { registerScriptAgent as adaptLegacyScript, type LegacyScriptFn } from "./script-agent.js"; import type { RunnerOptions } from "./types.js"; +const DEFAULT_CONVENTIONS = [FAIL_ON_ERROR_DECORATOR] as const; + export class Runner = Record> { private readonly options: RunnerOptions; readonly data: DataRegistry; - private readonly handlers = new Map>(); + private readonly scripts = new Registry>(); + private readonly decorators = new Registry>(); + private conventions: string[] = [...DEFAULT_CONVENTIONS]; private peer: RunnerPeer | null = null; private heartbeat: HeartbeatHandle | null = null; private unsubscribeDispatch: (() => void) | null = null; @@ -22,22 +32,36 @@ export class Runner = Record = {}) { this.options = options; this.data = options.data ?? (createDataRegistry() as DataRegistry); + this.registerDecorator(FAIL_ON_ERROR_DECORATOR, failOnError as DecoratorFn); + } + + registerScript(kind: string, fn: ScriptFn): void { + this.scripts.register(kind, fn); } - registerWorkItemHandler(handler: WorkItemHandler): void { - this.ensureHandlerRegistry(handler.kind).register(handler.name, handler); + registerDecorator(name: string, fn: DecoratorFn): void { + this.decorators.register(name, fn); + } + + addConvention(name: string): void { + if (!this.decorators.has(name)) { + throw new Error(`Unknown decorator: ${name}`); + } + if (!this.conventions.includes(name)) { + this.conventions.push(name); + } } - registerScriptAgent(name: string, fn: ScriptFn): void { - this.registerWorkItemHandler(createScriptAgent(fn, name)); + registerScriptAgent(name: string, fn: LegacyScriptFn): void { + adaptLegacyScript(this, name, fn); } - getWorkItemHandler(kind: string, name: string): WorkItemHandler | undefined { - return this.handlers.get(kind)?.get(name); + hasScript(kind: string): boolean { + return this.scripts.has(kind); } - hasWorkItemHandler(kind: string, name: string): boolean { - return this.handlers.get(kind)?.has(name) ?? false; + hasDecorator(name: string): boolean { + return this.decorators.has(name); } async start(): Promise { @@ -53,7 +77,13 @@ export class Runner = Record = Record { - let registry = this.handlers.get(kind); - if (registry === undefined) { - registry = new Registry(); - this.handlers.set(kind, registry); - } - return registry; - } } + +export type { WorkItemResult }; diff --git a/orchestrator-v2/packages/runner/src/script-agent.ts b/orchestrator-v2/packages/runner/src/script-agent.ts index 6f8c238..cadda5c 100644 --- a/orchestrator-v2/packages/runner/src/script-agent.ts +++ b/orchestrator-v2/packages/runner/src/script-agent.ts @@ -1,26 +1,22 @@ -import type { - WorkItem, - WorkItemExecutionContext, - WorkItemHandler, - WorkItemResult, -} from "@bifrost-ai/interfaces-work"; +import type { ScriptFn, WorkItem, WorkItemResult } from "@bifrost-ai/interfaces-work"; -export type ScriptFn = (ctx: { +import type { Runner } from "./runner.js"; + +export type LegacyScriptFn = (ctx: { workItem: WorkItem; cwd: string; - setState: WorkItemExecutionContext["setState"]; + setState: (state: Record) => Promise; }) => Promise | WorkItemResult; -export function createScriptAgent(fn: ScriptFn, name: string): WorkItemHandler { - return { - kind: "script", - name, - async run(workItem, ctx) { - const cwd = - typeof workItem.state.workingDir === "string" && workItem.state.workingDir.length > 0 - ? workItem.state.workingDir - : process.cwd(); - return fn({ workItem, cwd, setState: ctx.setState }); - }, - }; +export type { ScriptFn }; + +export function registerScriptAgent>( + runner: Runner, + name: string, + fn: LegacyScriptFn, +): void { + const script: ScriptFn = async (workItem, ctx) => + fn({ workItem, cwd: ctx.cwd, setState: ctx.setState }); + + runner.registerScript(name, script); } diff --git a/orchestrator-v2/packages/runner/src/script-context.ts b/orchestrator-v2/packages/runner/src/script-context.ts new file mode 100644 index 0000000..f017ddb --- /dev/null +++ b/orchestrator-v2/packages/runner/src/script-context.ts @@ -0,0 +1,41 @@ +import type { DataRegistry, ScriptContext, WorkItem } from "@bifrost-ai/interfaces-work"; + +import type { RpcClient } from "./rpc-client.js"; + +export function resolveScriptCwd(workItem: WorkItem): string { + return typeof workItem.state.workingDir === "string" && workItem.state.workingDir.length > 0 + ? workItem.state.workingDir + : process.cwd(); +} + +export function createLiveWorkItem(workItem: WorkItem): WorkItem { + return { + workItemId: workItem.workItemId, + kind: workItem.kind, + flow: [...workItem.flow], + metadata: workItem.metadata, + state: { ...workItem.state }, + }; +} + +export function createScriptContext>( + workItem: WorkItem, + rpc: RpcClient, + data: DataRegistry, +): { workItem: WorkItem; ctx: ScriptContext } { + const liveWorkItem = createLiveWorkItem(workItem); + + const ctx: ScriptContext = { + cwd: resolveScriptCwd(liveWorkItem), + data, + async setState(nextState) { + Object.assign(liveWorkItem.state, nextState); + await rpc.call("workItemSource.setState", { + workItemId: workItem.workItemId, + state: liveWorkItem.state, + }); + }, + }; + + return { workItem: liveWorkItem, ctx }; +} diff --git a/orchestrator-v2/packages/runner/src/script-stack.spec.ts b/orchestrator-v2/packages/runner/src/script-stack.spec.ts new file mode 100644 index 0000000..8f347f4 --- /dev/null +++ b/orchestrator-v2/packages/runner/src/script-stack.spec.ts @@ -0,0 +1,267 @@ +import type { DecoratorFn, ScriptFn, WorkItem } from "@bifrost-ai/interfaces-work"; +import { describe, expect } from "vite-plus/test"; +import test from "vitest-gwt"; + +import { Registry } from "./registry.js"; +import { + executeScriptStack, + normalizeScriptResult, + resolveStack, +} from "./script-stack.js"; + +type Context = { + workItem: WorkItem; + scripts: Registry; + decorators: Registry; + result: unknown; + error: Error | null; +}; + +const baseWorkItem = (): WorkItem => ({ + workItemId: "wi-1", + kind: "hunt", + flow: [], + state: {}, + metadata: {}, +}); + +const scriptContext = { + cwd: "/tmp", + data: { + get() { + return { + register() {}, + get() { + return undefined; + }, + has() { + return false; + }, + }; + }, + }, + async setState() {}, +}; + +describe("script-stack", () => { + test("runs script directly when flow is empty and no conventions", { + given: { a_script_registry_with_hunt }, + when: { executing_without_conventions }, + then: { script_ran }, + }); + + test("nests decorators outermost-first", { + given: { nested_decorators }, + when: { executing_nested_stack }, + then: { order_is_outer_to_inner }, + }); + + test("short-circuit decorator skips inner work", { + given: { skip_decorator }, + when: { executing_short_circuit }, + then: { script_never_ran }, + }); + + test("retry decorator calls next multiple times", { + given: { flaky_script_and_retry_decorator }, + when: { executing_with_retry }, + then: { script_succeeds_on_third_try }, + }); + + test("resolveStack throws for missing script", { + given: { empty_registries }, + when: { resolving_unknown_script }, + then: { script_error_thrown }, + }); + + test("resolveStack throws for missing decorator", { + given: { script_without_decorator }, + when: { resolving_unknown_decorator }, + then: { decorator_error_thrown }, + }); + + test("normalizeScriptResult treats WorkItemResult as-is", { + when: { normalizing_work_item_result }, + then: { result_is_preserved }, + }); + + test("normalizeScriptResult treats unknown as completed", { + when: { normalizing_unknown }, + then: { outcome_is_completed }, + }); +}); + +function a_script_registry_with_hunt(this: Context) { + this.workItem = baseWorkItem(); + this.scripts = new Registry(); + this.decorators = new Registry(); + this.scripts.register("hunt", async () => { + this.result = "meat"; + return this.result; + }); +} + +async function executing_without_conventions(this: Context) { + const stack = resolveStack(this.workItem, this.scripts, this.decorators, []); + this.result = await executeScriptStack(this.workItem, scriptContext, stack); +} + +function script_ran(this: Context) { + expect(this.result).toEqual({ outcome: "completed" }); +} + +function nested_decorators(this: Context) { + const order: string[] = []; + this.result = order; + this.workItem = { ...baseWorkItem(), flow: ["outer", "inner"] }; + this.scripts = new Registry(); + this.decorators = new Registry(); + + this.scripts.register("hunt", async () => { + order.push("script"); + return "done"; + }); + + this.decorators.register("outer", async (_wi, _ctx, next) => { + order.push("outer-before"); + await next(); + order.push("outer-after"); + }); + + this.decorators.register("inner", async (_wi, _ctx, next) => { + order.push("inner-before"); + await next(); + order.push("inner-after"); + }); +} + +async function executing_nested_stack(this: Context) { + const stack = resolveStack(this.workItem, this.scripts, this.decorators, []); + await executeScriptStack(this.workItem, scriptContext, stack); +} + +function order_is_outer_to_inner(this: Context) { + expect(this.result).toEqual([ + "outer-before", + "inner-before", + "script", + "inner-after", + "outer-after", + ]); +} + +function skip_decorator(this: Context) { + const state = { scriptRan: false }; + this.result = state; + this.workItem = { ...baseWorkItem(), flow: ["skip"] }; + this.scripts = new Registry(); + this.decorators = new Registry(); + + this.scripts.register("hunt", async () => { + state.scriptRan = true; + }); + + this.decorators.register("skip", async () => "skipped"); +} + +async function executing_short_circuit(this: Context) { + const stack = resolveStack(this.workItem, this.scripts, this.decorators, []); + await executeScriptStack(this.workItem, scriptContext, stack); +} + +function script_never_ran(this: Context) { + expect((this.result as { scriptRan: boolean }).scriptRan).toBe(false); +} + +function flaky_script_and_retry_decorator(this: Context) { + const state = { attempts: 0 }; + this.result = state; + this.workItem = { ...baseWorkItem(), flow: ["retry"] }; + this.scripts = new Registry(); + this.decorators = new Registry(); + + this.scripts.register("hunt", async () => { + state.attempts += 1; + if (state.attempts < 3) { + throw new Error("not yet"); + } + return "ok"; + }); + + this.decorators.register("retry", async (_wi, _ctx, next) => { + let tries = 0; + while (true) { + try { + return await next(); + } catch (error) { + if (++tries >= 3) { + throw error; + } + } + } + }); +} + +async function executing_with_retry(this: Context) { + const stack = resolveStack(this.workItem, this.scripts, this.decorators, []); + await executeScriptStack(this.workItem, scriptContext, stack); +} + +function script_succeeds_on_third_try(this: Context) { + expect((this.result as { attempts: number }).attempts).toBe(3); +} + +function empty_registries(this: Context) { + this.workItem = baseWorkItem(); + this.scripts = new Registry(); + this.decorators = new Registry(); +} + +function resolving_unknown_script(this: Context) { + try { + resolveStack(this.workItem, this.scripts, this.decorators, []); + this.error = null; + } catch (error) { + this.error = error as Error; + } +} + +function script_error_thrown(this: Context) { + expect(this.error?.message).toBe("Unknown script: hunt"); +} + +function script_without_decorator(this: Context) { + this.workItem = { ...baseWorkItem(), flow: ["missing"] }; + this.scripts = new Registry(); + this.decorators = new Registry(); + this.scripts.register("hunt", async () => "ok"); +} + +function resolving_unknown_decorator(this: Context) { + try { + resolveStack(this.workItem, this.scripts, this.decorators, []); + this.error = null; + } catch (error) { + this.error = error as Error; + } +} + +function decorator_error_thrown(this: Context) { + expect(this.error?.message).toBe("Unknown decorator: missing"); +} + +function normalizing_work_item_result(this: Context) { + this.result = normalizeScriptResult({ outcome: "paused" }); +} + +function result_is_preserved(this: Context) { + expect(this.result).toEqual({ outcome: "paused" }); +} + +function normalizing_unknown(this: Context) { + this.result = normalizeScriptResult(undefined); +} + +function outcome_is_completed(this: Context) { + expect(this.result).toEqual({ outcome: "completed" }); +} diff --git a/orchestrator-v2/packages/runner/src/script-stack.ts b/orchestrator-v2/packages/runner/src/script-stack.ts new file mode 100644 index 0000000..73f5bf7 --- /dev/null +++ b/orchestrator-v2/packages/runner/src/script-stack.ts @@ -0,0 +1,74 @@ +import type { + DecoratorFn, + ScriptContext, + ScriptFn, + WorkItem, + WorkItemResult, +} from "@bifrost-ai/interfaces-work"; +import { isWorkItemResult } from "@bifrost-ai/interfaces-work"; + +import type { Registry } from "./registry.js"; + +export type ResolvedStack = Record> = { + script: ScriptFn; + decorators: DecoratorFn[]; +}; + +export function resolveStack = Record>( + workItem: WorkItem, + scripts: Registry>, + decorators: Registry>, + conventions: readonly string[], +): ResolvedStack { + const script = scripts.get(workItem.kind); + if (script === undefined) { + throw new Error(`Unknown script: ${workItem.kind}`); + } + + const decoratorNames = [...conventions, ...workItem.flow]; + const resolvedDecorators: DecoratorFn[] = []; + + for (const name of decoratorNames) { + const decorator = decorators.get(name); + if (decorator === undefined) { + throw new Error(`Unknown decorator: ${name}`); + } + resolvedDecorators.push(decorator); + } + + return { script, decorators: resolvedDecorators }; +} + +export function composeStack>( + workItem: WorkItem, + ctx: ScriptContext, + script: ScriptFn, + decoratorFns: DecoratorFn[], +): () => Promise { + let inner: () => Promise = () => script(workItem, ctx); + + for (const decorator of decoratorFns.toReversed()) { + const next = inner; + inner = () => decorator(workItem, ctx, next); + } + + return inner; +} + +export function normalizeScriptResult(value: unknown): WorkItemResult { + if (isWorkItemResult(value)) { + return value; + } + + return { outcome: "completed" }; +} + +export async function executeScriptStack>( + workItem: WorkItem, + ctx: ScriptContext, + stack: ResolvedStack, +): Promise { + const run = composeStack(workItem, ctx, stack.script, stack.decorators); + const result = await run(); + return normalizeScriptResult(result); +} diff --git a/orchestrator-v2/packages/runner/src/work-item-execution-context.ts b/orchestrator-v2/packages/runner/src/work-item-execution-context.ts deleted file mode 100644 index 4201c20..0000000 --- a/orchestrator-v2/packages/runner/src/work-item-execution-context.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { - DataRegistry, - WorkItem, - WorkItemExecutionContext, - WorkItemHandler, -} from "@bifrost-ai/interfaces-work"; - -import type { RpcClient } from "./rpc-client.js"; -import type { Registry } from "./registry.js"; - -export function createRpcWorkItemExecutionContext>( - workItem: WorkItem, - rpc: RpcClient, - data: DataRegistry, - handlers: Map>, -): { workItem: WorkItem; ctx: WorkItemExecutionContext } { - const state = { ...workItem.state }; - - const liveWorkItem: WorkItem = { - workItemId: workItem.workItemId, - kind: workItem.kind, - name: workItem.name, - metadata: workItem.metadata, - state, - }; - - const ctx: WorkItemExecutionContext = { - data, - handlers: { - get(kind, name) { - return handlers.get(kind)?.get(name); - }, - has(kind, name) { - return handlers.get(kind)?.has(name) ?? false; - }, - }, - async setState(nextState) { - Object.assign(state, nextState); - await rpc.call("workItemSource.setState", { - workItemId: workItem.workItemId, - state, - }); - }, - }; - - return { workItem: liveWorkItem, ctx }; -} diff --git a/orchestrator-v2/packages/work-item-source-bifrost/src/bifrost-work-item-source.spec.ts b/orchestrator-v2/packages/work-item-source-bifrost/src/bifrost-work-item-source.spec.ts index c779020..929b9cf 100644 --- a/orchestrator-v2/packages/work-item-source-bifrost/src/bifrost-work-item-source.spec.ts +++ b/orchestrator-v2/packages/work-item-source-bifrost/src/bifrost-work-item-source.spec.ts @@ -112,8 +112,8 @@ describe("BifrostWorkItemSource", () => { await cleanup(); expect(workItems).toHaveLength(1); - expect(workItems[0].name).toBe("implementer"); - expect(workItems[0].kind).toBe("task"); + expect(workItems[0].kind).toBe("implementer"); + expect(workItems[0].flow).toEqual([]); }); it("should skip rune without agent tag", async () => { @@ -241,8 +241,7 @@ describe("BifrostWorkItemSource", () => { }); const workItemId = await source.createDraftWorkItem({ - kind: "task", - name: "implementer", + kind: "implementer", metadata: { title: "New work item", description: "Do the thing" }, }); @@ -366,8 +365,8 @@ describe("BifrostWorkItemSource", () => { expect(workItem).toBeDefined(); expect(workItem!.workItemId).toBe("rune-1"); - expect(workItem!.name).toBe("implementer"); - expect(workItem!.kind).toBe("task"); + expect(workItem!.kind).toBe("implementer"); + expect(workItem!.flow).toEqual([]); expect(workItem!.metadata.description).toBe("Test description"); expect(workItem!.metadata.dependencies).toEqual([ { target_id: "rune-2", relationship: "blocks" }, diff --git a/orchestrator-v2/packages/work-item-source-bifrost/src/bifrost-work-item-source.ts b/orchestrator-v2/packages/work-item-source-bifrost/src/bifrost-work-item-source.ts index 665ca32..c23a861 100644 --- a/orchestrator-v2/packages/work-item-source-bifrost/src/bifrost-work-item-source.ts +++ b/orchestrator-v2/packages/work-item-source-bifrost/src/bifrost-work-item-source.ts @@ -171,12 +171,16 @@ export class BifrostWorkItemSource implements WorkItemSource { return agentTag.slice(6); } + public static extractDecoratorNames(tags: string[]): string[] { + return tags + .filter((tag) => tag.startsWith("decorator:")) + .map((tag) => tag.slice("decorator:".length)); + } + public static buildCreateRuneRequest(input: CreateDraftWorkItemInput): CreateRuneRequest { const metadata = input.metadata ?? {}; const title = - typeof metadata.title === "string" && metadata.title.length > 0 - ? metadata.title - : `${input.kind}:${input.name}`; + typeof metadata.title === "string" && metadata.title.length > 0 ? metadata.title : input.kind; const description = typeof metadata.description === "string" ? metadata.description : undefined; const priority = typeof metadata.priority === "number" ? metadata.priority : 1; const parentId = typeof metadata.parentId === "string" ? metadata.parentId : undefined; @@ -185,8 +189,10 @@ export class BifrostWorkItemSource implements WorkItemSource { const tags = Array.isArray(metadata.tags) ? [...(metadata.tags as string[])] : ([] as string[]); - if (input.kind === "task") { - tags.push(`agent:${input.name}`); + tags.push(`agent:${input.kind}`); + + for (const decorator of input.flow ?? []) { + tags.push(`decorator:${decorator}`); } return { @@ -203,8 +209,8 @@ export class BifrostWorkItemSource implements WorkItemSource { public static mapToWorkItem(rune: RuneDetail, agentName: string): WorkItem { return { workItemId: rune.id, - kind: "task", - name: agentName, + kind: agentName, + flow: BifrostWorkItemSource.extractDecoratorNames(rune.tags ?? []), state: { ...rune.state }, metadata: rune as unknown as Record, }; diff --git a/orchestrator-v2/publish.js b/orchestrator-v2/publish.js index 1bedfcf..7c088f8 100755 --- a/orchestrator-v2/publish.js +++ b/orchestrator-v2/publish.js @@ -3,10 +3,11 @@ import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; -import { readFile, writeFile } from "node:fs/promises"; +import { readdir, readFile, writeFile } from "node:fs/promises"; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootPkgPath = join(__dirname, "package.json"); +const packagesDir = join(__dirname, "packages"); const bumpPatch = (version) => { const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); @@ -33,19 +34,93 @@ const setPackageVersion = async (pkgPath, version) => { await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); }; -// Package publish order (dependencies first) -const publishOrder = [ - "@bifrost-ai/interfaces-work", - "@bifrost-ai/engine", - "@bifrost-ai/engine-claude-code", - "@bifrost-ai/engine-cursor", - "@bifrost-ai/protocol", - "@bifrost-ai/orchestrator", - "@bifrost-ai/runner", - "@bifrost-ai/agent-3-task", -]; +const bifrostDeps = (pkg) => { + const deps = {}; + for (const depType of ["dependencies", "peerDependencies"]) { + if (pkg[depType]) { + Object.assign(deps, pkg[depType]); + } + } + return Object.keys(deps).filter((name) => name.startsWith("@bifrost-ai/")); +}; + +const discoverPublishablePackages = async () => { + const entries = await readdir(packagesDir, { withFileTypes: true }); + const packages = []; + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const dir = join(packagesDir, entry.name); + const pkgPath = join(dir, "package.json"); + + let contents; + try { + // oxlint-disable-next-line no-await-in-loop + contents = await readFile(pkgPath, "utf-8"); + } catch { + continue; + } + + let pkg; + try { + pkg = JSON.parse(contents); + } catch { + continue; + } + + if (typeof pkg.name !== "string" || pkg.name.length === 0) { + continue; + } + + if (pkg.private || pkg.name.includes("example")) { + continue; + } + + packages.push({ name: pkg.name, dir, pkgPath, pkg }); + } + + return packages; +}; + +const sortByDependencyOrder = (packages) => { + const names = new Set(packages.map((pkg) => pkg.name)); + const deps = new Map( + packages.map((pkg) => [pkg.name, bifrostDeps(pkg.pkg).filter((name) => names.has(name))]), + ); + const inDegree = new Map(packages.map((pkg) => [pkg.name, deps.get(pkg.name).length])); + const queue = packages.filter((pkg) => inDegree.get(pkg.name) === 0).map((pkg) => pkg.name); + const sorted = []; + + while (queue.length > 0) { + const name = queue.shift(); + sorted.push(name); + + for (const [pkgName, pkgDeps] of deps) { + if (!pkgDeps.includes(name)) { + continue; + } + + const nextDegree = inDegree.get(pkgName) - 1; + inDegree.set(pkgName, nextDegree); + if (nextDegree === 0) { + queue.push(pkgName); + } + } + } + + if (sorted.length !== packages.length) { + throw new Error("Circular dependency detected among publishable packages"); + } + + return sorted; +}; -const pkgDir = (pkgName) => join(__dirname, "packages", pkgName.replace(/.*?\//g, "")); +const publishablePackages = await discoverPublishablePackages(); +const publishOrder = sortByDependencyOrder(publishablePackages); +const packageByName = new Map(publishablePackages.map((pkg) => [pkg.name, pkg])); const originalRootContents = await readFile(rootPkgPath, "utf-8"); const currentVersion = JSON.parse(originalRootContents).version; @@ -58,7 +133,7 @@ const publishVersion = `${targetSemver}-${buildNumber}`; // Snapshot original package.json contents before any mutations const originalContents = new Map(); for (const pkgName of publishOrder) { - const pkgPath = join(pkgDir(pkgName), "package.json"); + const { pkgPath } = packageByName.get(pkgName); // oxlint-disable-next-line no-await-in-loop originalContents.set(pkgName, await readFile(pkgPath, "utf-8")); } @@ -67,8 +142,9 @@ try { await setPackageVersion(rootPkgPath, targetSemver); for (const pkgName of publishOrder) { + const { pkgPath } = packageByName.get(pkgName); // oxlint-disable-next-line no-await-in-loop - await setPackageVersion(join(pkgDir(pkgName), "package.json"), publishVersion); + await setPackageVersion(pkgPath, publishVersion); } console.log(`Building all packages (${publishVersion})...`); @@ -78,9 +154,9 @@ try { }); for (const pkgName of publishOrder) { - const currentPath = pkgDir(pkgName); + const { dir } = packageByName.get(pkgName); const opts = { - cwd: currentPath, + cwd: dir, stdio: "inherit", }; @@ -106,7 +182,7 @@ try { for (const pkgName of publishOrder) { try { - const pkgPath = join(pkgDir(pkgName), "package.json"); + const { pkgPath } = packageByName.get(pkgName); // oxlint-disable-next-line no-await-in-loop await writeFile(pkgPath, originalContents.get(pkgName)); } catch {