From ca6d071d97e4f001ea78f86efeafbc550a1c867d Mon Sep 17 00:00:00 2001 From: Nillo Date: Mon, 10 Aug 2026 02:48:56 +0200 Subject: [PATCH] feat(plugin): add durable multimodel orchestration --- README.md | 28 +- bun.lock | 23 +- packages/codex-delegator/README.md | 9 + packages/codex-delegator/package.json | 2 +- packages/opencode-codex-delegate/README.md | 13 + packages/opencode-codex-delegate/package.json | 20 +- .../opencode-codex-delegate/src/server.ts | 7 + packages/opencode-codex-delegate/src/tui.ts | 6 + .../tests/plugin.test.ts | 11 + packages/opencode-multimodel/PROVENANCE.md | 12 + packages/opencode-multimodel/README.md | 128 +- .../THIRD_PARTY_NOTICES.md | 9 + packages/opencode-multimodel/package.json | 12 +- packages/opencode-multimodel/scripts/build.ts | 15 + .../opencode-multimodel/src/collaborate.ts | 7 +- packages/opencode-multimodel/src/index.ts | 3 + packages/opencode-multimodel/src/opencode.ts | 385 +++++- packages/opencode-multimodel/src/options.ts | 261 +++- .../opencode-multimodel/src/orchestration.ts | 450 +++++++ packages/opencode-multimodel/src/script.ts | 611 +++++++++ packages/opencode-multimodel/src/server.ts | 423 ++++-- packages/opencode-multimodel/src/state.ts | 1185 +++++++++++++++-- packages/opencode-multimodel/src/tui.tsx | 839 +++++++++++- packages/opencode-multimodel/src/types.ts | 113 +- .../opencode-multimodel/src/workflow-files.ts | 88 ++ packages/opencode-multimodel/src/workflow.ts | 164 ++- .../tests/collaborate.test.ts | 33 + .../tests/composer.test.ts | 165 +++ .../tests/opencode.test.ts | 217 ++- .../tests/orchestration.test.ts | 179 +++ .../opencode-multimodel/tests/plugin.test.ts | 139 +- .../opencode-multimodel/tests/script.test.ts | 197 +++ .../opencode-multimodel/tests/state.test.ts | 182 +++ .../tests/workflow-files.test.ts | 54 + 34 files changed, 5545 insertions(+), 445 deletions(-) create mode 100644 packages/opencode-codex-delegate/src/server.ts create mode 100644 packages/opencode-codex-delegate/src/tui.ts create mode 100644 packages/opencode-multimodel/PROVENANCE.md create mode 100644 packages/opencode-multimodel/THIRD_PARTY_NOTICES.md create mode 100644 packages/opencode-multimodel/scripts/build.ts create mode 100644 packages/opencode-multimodel/src/orchestration.ts create mode 100644 packages/opencode-multimodel/src/script.ts create mode 100644 packages/opencode-multimodel/src/workflow-files.ts create mode 100644 packages/opencode-multimodel/tests/composer.test.ts create mode 100644 packages/opencode-multimodel/tests/orchestration.test.ts create mode 100644 packages/opencode-multimodel/tests/script.test.ts create mode 100644 packages/opencode-multimodel/tests/state.test.ts create mode 100644 packages/opencode-multimodel/tests/workflow-files.test.ts diff --git a/README.md b/README.md index a1ea664..3148337 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,15 @@ This repository is the Proof of Work OpenCode monorepo. It contains three independent public packages: +Current prerelease: **`0.2.0-alpha.0`** for all three packages. The alpha has +been built and exercised against OpenCode **1.18.15**. Keep the npm `alpha` +dist-tag explicit until the APIs graduate from prerelease. + | Package | Purpose | Alpha install | | --- | --- | --- | -| [`opencode-multimodel`](./packages/opencode-multimodel) | Multi-model fleets, collaboration modes, and declarative workflows for OpenCode. | `bun add opencode-multimodel@alpha` | -| [`codex-delegator`](./packages/codex-delegator) | Reusable Bun library for driving a locally authenticated Codex CLI. | `bun add codex-delegator@alpha` | -| [`opencode-codex-delegate`](./packages/opencode-codex-delegate) | OpenCode plugin and provider backed by `codex-delegator`. | `bun add opencode-codex-delegate@alpha` | +| [`opencode-multimodel`](./packages/opencode-multimodel) | Durable TEAM/WORKFLOW fleets, collaboration modes, dashboards, and workflows for OpenCode. | `bun add opencode-multimodel@alpha` | +| [`codex-delegator`](./packages/codex-delegator) | Reusable Bun library for driving a locally authenticated Codex CLI; this is the shared runtime, not an OpenCode plugin. | `bun add codex-delegator@alpha` | +| [`opencode-codex-delegate`](./packages/opencode-codex-delegate) | OpenCode plugin, tools, and selectable AI SDK provider backed by `codex-delegator`. | `bun add opencode-codex-delegate@alpha` | All packages are configured to publish on npm under the `alpha` dist-tag. Once released, OpenCode plugin configuration should keep that tag explicit while the APIs are pre-release: @@ -20,15 +24,27 @@ All packages are configured to publish on npm under the `alpha` dist-tag. Once r } ``` +OpenCode discovers separate `./server` and `./tui` entrypoints for both plugin +packages. `opencode-codex-delegate` registers `codex-delegate/` selections; +`opencode-multimodel` can use those models as ordinary fleet seats without +creating a second nested worktree. + The Codex packages use the locally installed Codex CLI. Authentication remains owned by that CLI; this repository does not contain, copy, or publish Codex credentials. +## Alpha validation + +The `0.2.0-alpha.0` release gate covers unit and integration tests, package +typechecks and builds, npm tarball inspection, SQLite recovery/concurrency, +composer routing, workflow sandbox boundaries, worktree fail-closed behavior, +real Codex app-server delegation, and a combined OpenCode 1.18.15 TEAM run with +child-session and Codex-thread reuse after restart. Live Codex tests remain +opt-in because they consume an authenticated local account. + ## Development ```sh bun install -bun run typecheck -bun run test -bun run build +bun run verify ``` Run `bun run publish:alpha` only after the full verification succeeds and npm authentication is configured. The script publishes `codex-delegator` first so the dependent OpenCode plugin can resolve it. diff --git a/bun.lock b/bun.lock index 2c58920..e90ab27 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ }, "packages/codex-delegator": { "name": "codex-delegator", - "version": "0.1.0", + "version": "0.2.0-alpha.0", "devDependencies": { "@tsconfig/bun": "1.0.9", "@types/bun": "1.3.13", @@ -16,11 +16,12 @@ }, "packages/opencode-codex-delegate": { "name": "opencode-codex-delegate", - "version": "0.1.0", + "version": "0.2.0-alpha.0", "dependencies": { "@ai-sdk/provider": "3.0.8", - "@opencode-ai/plugin": "^1.18.10", - "codex-delegator": "^0.1.0", + "@opencode-ai/plugin": "1.18.15", + "@opencode-ai/sdk": "1.18.15", + "codex-delegator": "^0.2.0-alpha.0", }, "devDependencies": { "@tsconfig/bun": "1.0.9", @@ -30,10 +31,10 @@ }, "packages/opencode-multimodel": { "name": "opencode-multimodel", - "version": "0.1.0", + "version": "0.2.0-alpha.0", "dependencies": { - "@opencode-ai/plugin": "^1.18.15", - "@opencode-ai/sdk": "^1.18.15", + "@opencode-ai/plugin": "1.18.15", + "@opencode-ai/sdk": "1.18.15", }, "devDependencies": { "@opentui/core": "0.4.5", @@ -41,7 +42,7 @@ "@opentui/solid": "0.4.5", "@tsconfig/bun": "1.0.9", "@types/bun": "1.3.13", - "solid-js": "1.9.10", + "solid-js": "1.9.12", "typescript": "5.8.2", }, "peerDependencies": { @@ -315,15 +316,15 @@ "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "seroval": ["seroval@1.3.2", "", {}, "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ=="], + "seroval": ["seroval@1.5.6", "", {}, "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA=="], - "seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="], + "seroval-plugins": ["seroval-plugins@1.5.6", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="], + "solid-js": ["solid-js@1.9.12", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="], "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], diff --git a/packages/codex-delegator/README.md b/packages/codex-delegator/README.md index cc7b647..f8f28aa 100644 --- a/packages/codex-delegator/README.md +++ b/packages/codex-delegator/README.md @@ -2,6 +2,10 @@ `codex-delegator` is a reusable Bun library for running bounded tasks through a locally installed and authenticated OpenAI Codex CLI. It prefers one persistent `codex app-server` process per delegate seat and falls back to `codex exec --json` when app-server is unavailable. +Current prerelease: **`0.2.0-alpha.0`**. This is the reusable runtime library, +not an OpenCode plugin; install `opencode-codex-delegate@alpha` when OpenCode +plugin/provider integration is required. + ## Install ```sh @@ -60,3 +64,8 @@ await delegate.closeAll(); `CodexDelegator` accepts `executable`, `stateDir`, `serviceName`, and `appServerArgv` overrides. `TurnInput.reasoningEffort` maps to app-server's per-turn `effort` override. The remaining constructor hooks are intended for alternate transports and deterministic testing. Set `CODEX_DELEGATOR_LIVE=1` to enable read-only live tests. Write, review, and scale live tests have separate opt-in flags in `tests/live.test.ts`. + +The `0.2.0-alpha.0` gate was exercised with the locally authenticated Codex CLI, +including app-server probe, persistent delegation, restart/resume, native review, +steering, cancellation, concurrency, and multiple seats. These live checks remain +explicit opt-ins for downstream development environments. diff --git a/packages/codex-delegator/package.json b/packages/codex-delegator/package.json index 3777a04..07dd411 100644 --- a/packages/codex-delegator/package.json +++ b/packages/codex-delegator/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "codex-delegator", - "version": "0.1.0", + "version": "0.2.0-alpha.0", "description": "A reusable Bun library for delegating work to a local OpenAI Codex CLI", "type": "module", "license": "MIT", diff --git a/packages/opencode-codex-delegate/README.md b/packages/opencode-codex-delegate/README.md index a366e87..1c4e387 100644 --- a/packages/opencode-codex-delegate/README.md +++ b/packages/opencode-codex-delegate/README.md @@ -2,6 +2,10 @@ An OpenCode plugin that registers the locally authenticated OpenAI Codex CLI as a selectable provider and also exposes bounded delegation and native-review tools. It is a host adapter over [`codex-delegator`](../codex-delegator). +Current prerelease: **`0.2.0-alpha.0`**, validated with OpenCode **1.18.15**. +The package retains separate `./server` and `./tui` exports so OpenCode can load +both plugin surfaces through one installation. + ## Install Install the alpha channel by adding the tagged package to `opencode.json`: @@ -108,3 +112,12 @@ OpenCode calls the plugin's `dispose` hook on shutdown, and the shared tool/prov ## Provider package entrypoint The package exports `opencode-codex-delegate/provider` for hosts that want to configure the AI SDK provider manually. Normal OpenCode use should load the plugin: its config hook points OpenCode at the bundled provider entrypoint and supplies lifecycle-managed runtime state automatically. + +## Alpha validation + +The `0.2.0-alpha.0` release gate covers the server/TUI module shapes, provider +registration and streaming, idempotent retry behavior, approval bridging, +managed-worktree restart/resume, review, steering, cancellation, concurrency, +and a real combined OpenCode TEAM run. Provider-mode Codex owns its worktree; +`opencode-multimodel` intentionally does not wrap it in another OpenCode +workspace. diff --git a/packages/opencode-codex-delegate/package.json b/packages/opencode-codex-delegate/package.json index 4dcfdcc..7392518 100644 --- a/packages/opencode-codex-delegate/package.json +++ b/packages/opencode-codex-delegate/package.json @@ -1,19 +1,28 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "opencode-codex-delegate", - "version": "0.1.0", + "version": "0.2.0-alpha.0", "description": "Pluggable OpenCode provider and delegation tools backed by the local OpenAI Codex CLI", "type": "module", "license": "MIT", "sideEffects": false, "engines": { - "bun": ">=1.3.0" + "bun": ">=1.3.0", + "opencode": ">=1.18.15" }, "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./server": { + "types": "./dist/server.d.ts", + "import": "./dist/server.js" + }, + "./tui": { + "types": "./dist/tui.d.ts", + "import": "./dist/tui.js" + }, "./factory": { "types": "./dist/plugin.d.ts", "import": "./dist/plugin.js" @@ -34,15 +43,16 @@ }, "scripts": { "clean": "bun -e 'import { rm } from \"node:fs/promises\"; await rm(\"dist\", { recursive: true, force: true })'", - "build": "bun run clean && bun build src/index.ts src/plugin.ts src/provider.ts --outdir dist --target bun --format esm --packages external --splitting && tsc -p tsconfig.build.json", + "build": "bun run clean && bun build src/index.ts src/server.ts src/tui.ts src/plugin.ts src/provider.ts --outdir dist --target bun --format esm --packages external --splitting && tsc -p tsconfig.build.json", "test": "bun test", "typecheck": "tsc --noEmit", "prepublishOnly": "bun run test && bun run typecheck && bun run build" }, "dependencies": { "@ai-sdk/provider": "3.0.8", - "@opencode-ai/plugin": "^1.18.10", - "codex-delegator": "^0.1.0" + "@opencode-ai/plugin": "1.18.15", + "@opencode-ai/sdk": "1.18.15", + "codex-delegator": "^0.2.0-alpha.0" }, "devDependencies": { "@tsconfig/bun": "1.0.9", diff --git a/packages/opencode-codex-delegate/src/server.ts b/packages/opencode-codex-delegate/src/server.ts new file mode 100644 index 0000000..d61f56a --- /dev/null +++ b/packages/opencode-codex-delegate/src/server.ts @@ -0,0 +1,7 @@ +import type { PluginModule } from "@opencode-ai/plugin"; +import { createCodexDelegatePlugin } from "./plugin.ts"; + +export default { + id: "opencode-codex-delegate", + server: createCodexDelegatePlugin(), +} satisfies PluginModule; diff --git a/packages/opencode-codex-delegate/src/tui.ts b/packages/opencode-codex-delegate/src/tui.ts new file mode 100644 index 0000000..4a6440d --- /dev/null +++ b/packages/opencode-codex-delegate/src/tui.ts @@ -0,0 +1,6 @@ +import type { TuiPluginModule } from "@opencode-ai/plugin/tui"; + +export default { + id: "opencode-codex-delegate", + async tui() {}, +} satisfies TuiPluginModule; diff --git a/packages/opencode-codex-delegate/tests/plugin.test.ts b/packages/opencode-codex-delegate/tests/plugin.test.ts index 13c0165..14b9a15 100644 --- a/packages/opencode-codex-delegate/tests/plugin.test.ts +++ b/packages/opencode-codex-delegate/tests/plugin.test.ts @@ -22,6 +22,8 @@ import { } from "codex-delegator"; import { createCodexDelegatePlugin } from "../src/plugin.ts"; import { createCodexDelegateProvider } from "../src/provider.ts"; +import serverModule from "../src/server.ts"; +import tuiModule from "../src/tui.ts"; class FakeDelegate { readonly creates: CreateInput[] = []; @@ -153,6 +155,15 @@ class FakeDelegate { } describe("OpenCode Codex delegate plugin", () => { + test("publishes separate OpenCode server and TUI modules", () => { + expect(serverModule.id).toBe("opencode-codex-delegate"); + expect(typeof serverModule.server).toBe("function"); + expect("tui" in serverModule).toBe(false); + expect(tuiModule.id).toBe("opencode-codex-delegate"); + expect(typeof tuiModule.tui).toBe("function"); + expect("server" in tuiModule).toBe(false); + }); + test("keeps the main entrypoint limited to one deduplicated plugin function", async () => { const entrypoint = await import("../src/index.ts"); expect( diff --git a/packages/opencode-multimodel/PROVENANCE.md b/packages/opencode-multimodel/PROVENANCE.md new file mode 100644 index 0000000..0729d32 --- /dev/null +++ b/packages/opencode-multimodel/PROVENANCE.md @@ -0,0 +1,12 @@ +# Confined interpreter provenance + +The implementation in `src/script.ts` is an original, purpose-limited +tree-walking interpreter created for this plugin. Its allowed-capability model, +bounded host calls, and data-only interpreter boundary are derived from the +MIT-licensed OpenCode CodeMode design in +`packages/codemode/src/interpreter/runtime.ts` as inspected from the OpenCode +1.18.15 source tree. No source file from CodeMode is copied verbatim. + +The interpreter accepts one JavaScript/TypeScript-style expression (optionally +wrapped in an `export default` arrow function) and exposes only `args`, +`agent`, `parallel`, `pipeline`, `phase`, and `log`. diff --git a/packages/opencode-multimodel/README.md b/packages/opencode-multimodel/README.md index bf40c88..49632c5 100644 --- a/packages/opencode-multimodel/README.md +++ b/packages/opencode-multimodel/README.md @@ -2,6 +2,8 @@ Multi-model fleets, collaboration modes, and declarative workflows for original [OpenCode](https://github.com/anomalyco/opencode). +Current prerelease: **`0.2.0-alpha.0`**, validated with OpenCode **1.18.15**. + This package has two surfaces: - `opencode-multimodel/core` is a reusable orchestration library with an injected `AgentRunner`. @@ -14,9 +16,13 @@ The collaboration behavior is adapted from the tested Poly orchestration engine, - `/lead` selects the model that owns assignments, synthesis, and verdicts. - `/fleet` opens a dedicated fleet screen. - `/collab` runs `lead`, `pair`, `round`, `council`, `orchestrate`, `handoff`, `panel`, `deliberate`, or `jury` mode. -- `/workflow` selects and runs a saved declarative DAG workflow. -- `/workflows` opens workflow definitions and run history. -- Server tools provide the same capabilities in non-TUI OpenCode sessions. +- `/workflow` selects and runs a saved DAG or confined script workflow. +- `/mode` and the clickable composer badge select `SINGLE`, `TEAM`, or + `WORKFLOW` without replacing OpenCode's native prompt implementation. +- `/workflows`, `/runs`, and `/graph` expose definitions, the durable event + ledger, live run status, and fleet routing. +- Server tools provide explicit fleet, collaboration, run-control, and + workflow capabilities in API/headless OpenCode sessions. Each fleet seat runs in its own child OpenCode session. Parallel modes therefore use multiple provider models at the same time, subject to the providers' own concurrency and rate limits. @@ -30,9 +36,25 @@ Install the alpha channel from npm: [ "opencode-multimodel@alpha", { + "databasePath": ".opencode/multimodel.sqlite", "defaultMode": "council", + "maxWorkers": 8, "maxParallel": 3, - "maxWorkers": 8 + "composer": { + "enabled": true, + "initial": "single", + "autoRoute": false + }, + "workflows": { + "scripts": false, + "directories": [".opencode/workflows"], + "timeoutMs": 300000, + "maxAgentCalls": 64 + }, + "retention": { + "runs": 100, + "events": 10000 + } } ] ] @@ -41,7 +63,17 @@ Install the alpha channel from npm: OpenCode loads the package's separate `./server` and `./tui` exports. The server entry registers tools and command templates; the TUI entry registers slash commands and additional screens. -On first use, the plugin creates `.opencode/multimodel.json` and adds one default model from each connected provider. Use `/lead` to select the lead. Fleet state, workflow definitions, and the last 100 workflow runs are shared through that file. +On first use, the plugin creates `.opencode/multimodel.sqlite` with SQLite WAL, +a busy timeout, transactions, and file mode `0600`. It adds one default model +from each connected provider. The database stores fleet configuration, composer +modes, definitions, runs, steps, agent calls, child sessions, preserved +workspaces, leases, and an append-only event ledger. It never stores provider +credentials, tokens, or environment variables. + +An existing `.opencode/multimodel.json` is imported once and left untouched. +The deprecated `statePath` option can point at another JSON import source. +Unfinished expired runs become `interrupted`; resume is always explicit. +Retention removes only older terminal runs, never active runs. You can provide an explicit fleet in plugin options: @@ -62,17 +94,19 @@ You can provide an explicit fleet in plugin options: "modelID": "claude-sonnet-4-5" }, "agent": "plan", - "enabled": true + "enabled": true, + "isolation": "shared" }, { "id": "codex", "role": "implementation specialist", "model": { "providerID": "codex-delegate", - "modelID": "gpt-5.6-codex" + "modelID": "gpt-5.6-sol" }, "agent": "plan", - "enabled": true + "enabled": true, + "isolation": "worktree" } ] } @@ -84,7 +118,7 @@ You can provide an explicit fleet in plugin options: ## Codex delegate participation -The multi-model plugin treats every seat as an ordinary OpenCode `{ providerID, modelID }` selection. A delegate can participate directly when its plugin exposes Codex as a real OpenCode provider, for example `codex-delegate/gpt-5.6-codex`. +The multi-model plugin treats every seat as an ordinary OpenCode `{ providerID, modelID }` selection. A delegate can participate directly when its plugin exposes Codex as a real OpenCode provider, for example `codex-delegate/gpt-5.6-sol`. Authentication and billing remain the delegate provider's responsibility. If that provider uses the Codex CLI subscription session, child fleet calls use the same subscription path and share its rate limits. This plugin does not convert API-key billing into subscription usage or bypass provider limits. @@ -103,16 +137,18 @@ A tool-only delegate cannot be selected as a fleet model. It must expose a provi | `panel` / `deliberate` | One structured ballot round; lead judges. | | `jury` | Two structured ballot rounds with rebuttal; lead judges. | -All modes enforce a model-call budget. Worker failures are preserved in council/orchestrate output so a healthy lead can still synthesize. Recursive multi-model tools are disabled inside child sessions. +All modes enforce a model-call budget. Worker failures are preserved in council/orchestrate output so a healthy lead can still synthesize. Nested multi-model and Codex-delegate orchestration tools are disabled inside child sessions, while ordinary OpenCode implementation tools remain available. Runs may be foreground or background and can be inspected, steered, cancelled, or explicitly resumed through `multimodel_run`. -## Declarative workflows +## Workflows -Workflows are JSON DAGs. They do not execute generated JavaScript. +`kind: "dag"` workflows are safe JSON dependency graphs. The `kind` may be +omitted for compatibility with definitions saved by `0.1.x`. Save a definition with the `multimodel_workflow` tool using `action: "save"` and JSON like: ```json { + "kind": "dag", "name": "implementation-review", "description": "Research and review in parallel, then merge", "maxParallel": 2, @@ -139,6 +175,47 @@ Save a definition with the `multimodel_workflow` tool using `action: "save"` and Steps become runnable when all dependencies finish. Ready steps run with bounded concurrency. `${input}` and `${step-id}` placeholders are interpolated from the workflow input and prior step results. Cycles, duplicate IDs, missing dependencies, and more than 64 steps are rejected before execution. +Experimental `kind: "script"` workflows are disabled by default. When +`workflows.scripts` is enabled, source runs in the bundled tree-walking +interpreter—not `eval`, `Function`, Node, or Bun. The accepted +JavaScript/TypeScript-style expression surface contains only `args`, `agent`, +`parallel`, `pipeline`, `phase`, and `log`: + +```json +{ + "kind": "script", + "name": "independent-review", + "source": "export default async ({ agent, parallel }) => parallel([agent({ prompt: 'Review security', memberID: 'claude' }), agent({ prompt: 'Review tests', memberID: 'codex' })])" +} +``` + +Imports, filesystem/network access, `process`, `Bun`, host globals, dynamic +evaluation, and prototype traversal are rejected. Source is limited to 500 kB, +64 agent calls, six parallel calls, and a five-minute default timeout. OpenCode +permission is bound to `:`, so editing source +requires new permission. Resume reuses only the exact contiguous call prefix +whose index, prompt, model/agent options, and isolation still match. + +Use `multimodel_workflow` to pause at a safe boundary, resume, stop active child +sessions, or restart an agent step. Worktrees remain available for inspection +until `multimodel_run` performs explicit `cleanup-workspaces`; the plugin never +auto-merges them. + +## Native composer + +The TUI registers replace-slots for the home and session composers but renders +OpenCode's own `api.ui.Prompt` inside them. `SINGLE` calls native submit without +modification. `TEAM` rewrites ordinary input to `/collab …`, and +`WORKFLOW` rewrites it to `/workflow …`. Attachments remain on the native +prompt object. Shell mode, existing slash commands, and leading `@` input are +never rewritten. + +`composer.autoRoute` defaults to `false`. When enabled, explicit multi-model +language selects TEAM and only an exact `workflow:` or +`workflow ` reference selects WORKFLOW. The visible badge remains a +manual override. API/headless calls do not infer intent; use commands and tools +explicitly. + ## Reusable core ```ts @@ -170,12 +247,21 @@ const result = await collaborate( ## Safety and current host limits -- Parallel seats can share a project directory. Use OpenCode's read-only/plan agent for reviewers, and avoid assigning simultaneous write work to overlapping files. +- Fleet members default to `isolation: "shared"`. `isolation: "worktree"` uses + OpenCode's experimental workspace API and fails closed if creation is + unavailable; it never silently falls back to the shared checkout. +- `codex-delegate` retains its own worktree ownership, so the multimodel adapter + does not create a nested OpenCode worktree for that provider. - Server tool runs request OpenCode permission before starting multi-model calls. -- TUI runs are explicit user actions from `/collab` or `/workflow`. -- Original OpenCode does not expose a plugin hook that transparently replaces every prompt on the default session screen. Collaboration therefore runs through the plugin commands/tools and dedicated screens rather than silently intercepting normal prompts. +- TUI mode routing is visible in the clickable composer badge and `/mode`. - The plugin does not patch OpenCode and does not require the Poly fork. +OpenCode 1.18.15's established server/TUI plugin API remains the integration +surface because it provides custom tools, replace-slots, routes, keymaps, and +native Prompt access. The newer v2 client is used for explicit server/headless +connections, but does not yet replace those host capabilities. The plugin keeps +the two entrypoints separate so this can evolve without a package split. + ## Development ```sh @@ -185,4 +271,14 @@ bun run typecheck bun run build ``` -The test suite covers collaboration ordering and concurrency, jury voting, call budgets, task parsing, workflow DAG validation/runtime behavior, OpenCode child-session reuse, provider discovery, and separate server/TUI plugin module shapes. +The test suite covers SQLite concurrency/migration/idempotency/recovery and +retention, composer routing and bypasses, collaboration ordering and +cancellation, DAG pause/resume, confined-script escapes/hash/timeout/budgets, +child-session restart reuse, worktree fail-closed behavior and cleanup, provider +discovery, and separate server/TUI module shapes. + +The `0.2.0-alpha.0` live gate additionally installs the delegate and multi-model +plugins one at a time with OpenCode's installer, runs a real TEAM `pair` across +OpenAI and Codex, verifies durable child-session and Codex-thread reuse after a +process restart, and confirms that delegate isolation creates only the delegate's +managed worktree rather than a nested OpenCode workspace. diff --git a/packages/opencode-multimodel/THIRD_PARTY_NOTICES.md b/packages/opencode-multimodel/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..54078e0 --- /dev/null +++ b/packages/opencode-multimodel/THIRD_PARTY_NOTICES.md @@ -0,0 +1,9 @@ +# Third-party notices + +The confined workflow runtime was designed against the public behavior and +security model of OpenCode's `@opencode-ai/codemode` package. OpenCode is +Copyright SST and contributors and is licensed under the MIT License. + +No Node/Bun evaluator is used. `opencode-multimodel` contains its own small +parser and tree-walking evaluator for the six documented workflow primitives. +The package's MIT `LICENSE` applies to that implementation. diff --git a/packages/opencode-multimodel/package.json b/packages/opencode-multimodel/package.json index 865bdb0..25a65b0 100644 --- a/packages/opencode-multimodel/package.json +++ b/packages/opencode-multimodel/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "opencode-multimodel", - "version": "0.1.0", + "version": "0.2.0-alpha.0", "description": "Multi-model fleets, collaboration modes, and declarative workflows for OpenCode", "type": "module", "license": "MIT", @@ -31,6 +31,8 @@ "files": [ "dist", "README.md", + "PROVENANCE.md", + "THIRD_PARTY_NOTICES.md", "LICENSE" ], "publishConfig": { @@ -38,14 +40,14 @@ "tag": "alpha" }, "scripts": { - "build": "bun build src/server.ts src/tui.tsx src/index.ts --outdir dist --target bun --format esm --packages external && tsc -p tsconfig.build.json", + "build": "bun run scripts/build.ts && tsc -p tsconfig.build.json", "test": "bun test", "typecheck": "tsc --noEmit", "prepublishOnly": "bun run test && bun run typecheck && bun run build" }, "dependencies": { - "@opencode-ai/plugin": "^1.18.15", - "@opencode-ai/sdk": "^1.18.15" + "@opencode-ai/plugin": "1.18.15", + "@opencode-ai/sdk": "1.18.15" }, "peerDependencies": { "@opentui/core": ">=0.4.5", @@ -73,7 +75,7 @@ "@opentui/solid": "0.4.5", "@tsconfig/bun": "1.0.9", "@types/bun": "1.3.13", - "solid-js": "1.9.10", + "solid-js": "1.9.12", "typescript": "5.8.2" }, "keywords": [ diff --git a/packages/opencode-multimodel/scripts/build.ts b/packages/opencode-multimodel/scripts/build.ts new file mode 100644 index 0000000..5c4fdfd --- /dev/null +++ b/packages/opencode-multimodel/scripts/build.ts @@ -0,0 +1,15 @@ +import solidPlugin from "@opentui/solid/bun-plugin"; + +const result = await Bun.build({ + entrypoints: ["src/server.ts", "src/tui.tsx", "src/index.ts"], + outdir: "dist", + target: "bun", + format: "esm", + packages: "external", + plugins: [solidPlugin], +}); + +if (!result.success) { + result.logs.forEach((log) => console.error(log)); + process.exit(1); +} diff --git a/packages/opencode-multimodel/src/collaborate.ts b/packages/opencode-multimodel/src/collaborate.ts index a2419e1..3bc45a9 100644 --- a/packages/opencode-multimodel/src/collaborate.ts +++ b/packages/opencode-multimodel/src/collaborate.ts @@ -45,6 +45,7 @@ export async function collaborate( lead, workers, budget, + nextCall: 0, replies: [] as AgentReply[], log: [] as string[], }; @@ -89,6 +90,7 @@ type CollaborationContext = { lead: FleetMember; workers: FleetMember[]; budget: AgentBudget; + nextCall: number; replies: AgentReply[]; log: string[]; }; @@ -105,7 +107,7 @@ async function leadOnly( "lead answer", ); context.log.push(`human → lead:${context.lead.id} → human`); - return result(context, mode, final); + return result(context, mode, final, [context.lead]); } async function pair( @@ -334,12 +336,15 @@ async function invoke( context.budget.spend(`${member.id}/${phase}`); activity(context, member, phase, detail); try { + const callIndex = context.nextCall++; const reply = await context.runner.run({ parentSessionID: context.parentSessionID, member, prompt, system: collaborationSystem(member, context.lead, context.participants), signal: context.options.signal, + runID: context.options.runID, + callIndex, }); context.replies.push(reply); activity(context, member, "done", detail); diff --git a/packages/opencode-multimodel/src/index.ts b/packages/opencode-multimodel/src/index.ts index 6d599c9..2d5765c 100644 --- a/packages/opencode-multimodel/src/index.ts +++ b/packages/opencode-multimodel/src/index.ts @@ -2,7 +2,10 @@ export * from "./budget.ts"; export * from "./collaborate.ts"; export * from "./jury.ts"; export * from "./opencode.ts"; +export * from "./orchestration.ts"; export * from "./options.ts"; +export * from "./script.ts"; export * from "./state.ts"; export * from "./types.ts"; export * from "./workflow.ts"; +export * from "./workflow-files.ts"; diff --git a/packages/opencode-multimodel/src/opencode.ts b/packages/opencode-multimodel/src/opencode.ts index 0508d20..1b52aa0 100644 --- a/packages/opencode-multimodel/src/opencode.ts +++ b/packages/opencode-multimodel/src/opencode.ts @@ -1,3 +1,5 @@ +import type { OpencodeClient } from "@opencode-ai/sdk"; +import type { StateStore } from "./state.ts"; import type { AgentReply, AgentRunner, @@ -12,11 +14,12 @@ type ClientResponse = { error?: unknown; }; -type OpenCodeClient = { +type AgentClient = { session: { create(input: { parentID?: string; title?: string; + workspaceID?: string; }): Promise>; prompt(input: { sessionID: string; @@ -25,137 +28,416 @@ type OpenCodeClient = { system?: string; tools?: Record; parts: Array<{ type: "text"; text: string }>; - }): Promise< - ClientResponse<{ info?: { error?: unknown }; parts: unknown[] }> - >; + }): Promise>; abort(input: { sessionID: string }): Promise>; }; provider?: { list(): Promise>; }; + experimental?: { + workspace?: { + create(input: { + type: string; + branch?: string | null; + }): Promise>; + remove(input: { id: string }): Promise>; + }; + }; }; type ProviderList = { all: Array<{ id: string; name: string; - models: Record; + models: Record; }>; connected?: string[]; default?: Record; }; -const BLOCKED_RECURSIVE_TOOLS = { +type ActiveChild = { + sessionID: string; + member: FleetMember; + runID?: string; +}; + +const BLOCKED_NESTED_ORCHESTRATION_TOOLS = { multimodel_collab: false, multimodel_fleet: false, + multimodel_run: false, multimodel_workflow: false, + codex_delegate: false, + codex_review: false, + codex_status: false, + codex_steer: false, + codex_cancel: false, + codex_probe: false, }; export function asOpenCodeClient(client: unknown) { - return client as OpenCodeClient; + return client as AgentClient; +} + +export function adaptPluginClient(client: OpencodeClient): AgentClient { + return { + session: { + create(input) { + return client.session.create({ + body: { + parentID: input.parentID, + title: input.title, + }, + }); + }, + prompt(input) { + return client.session.prompt({ + path: { id: input.sessionID }, + body: { + model: input.model, + agent: input.agent, + system: input.system, + tools: input.tools, + parts: input.parts, + }, + }); + }, + abort(input) { + return client.session.abort({ path: { id: input.sessionID } }); + }, + }, + provider: { + list() { + return client.provider.list(); + }, + }, + }; } export class OpenCodeAgentRunner implements AgentRunner { - private readonly sessions = new Map(); - private readonly active = new Map>(); + private readonly sessions = new Map(); + private readonly active = new Map>(); - constructor(private readonly client: OpenCodeClient) {} + constructor( + private readonly client: AgentClient, + private readonly store?: StateStore, + ) {} async run(input: RunAgentInput): Promise { input.signal?.throwIfAborted(); + const cached = await this.cached(input); + if (cached) return cached; const key = sessionKey(input); - const sessionID = - this.sessions.get(key) ?? (await this.createSession(input)); - const active = this.active.get(input.parentSessionID) ?? new Set(); - active.add(sessionID); + const session = await this.findOrCreateSession(key, input); + const active = this.active.get(input.parentSessionID) ?? + new Map(); + const activeKey = `${input.runID ?? ""}\0${session.sessionID}\0${crypto.randomUUID()}`; + active.set(activeKey, { + sessionID: session.sessionID, + member: input.member, + runID: input.runID, + }); this.active.set(input.parentSessionID, active); - const abort = () => void this.client.session.abort({ sessionID }); + const abort = () => void this.client.session.abort({ + sessionID: session.sessionID, + }); input.signal?.addEventListener("abort", abort, { once: true }); + await this.recordCall(input, "running", session.sessionID); try { input.signal?.throwIfAborted(); const response = await this.client.session.prompt({ - sessionID, + sessionID: session.sessionID, model: input.member.model, agent: input.member.agent, system: input.system, - tools: BLOCKED_RECURSIVE_TOOLS, + tools: BLOCKED_NESTED_ORCHESTRATION_TOOLS, parts: [{ type: "text", text: input.prompt }], }); - if (response.error) + if (response.error) { throw new Error(`OpenCode prompt failed: ${describe(response.error)}`); - if (response.data?.info?.error) + } + if (response.data?.info?.error) { throw new Error( `OpenCode model failed: ${describe(response.data.info.error)}`, ); + } const text = (response.data?.parts ?? []) .filter(isTextPart) .map((part) => part.text) .join("\n") .trim(); - if (!text) + if (!text) { throw new Error( `OpenCode returned no text for fleet member ${input.member.id}.`, ); + } + await this.recordCall( + input, + "completed", + session.sessionID, + text, + ); return { memberID: input.member.id, - sessionID, + sessionID: session.sessionID, model: input.member.model, text, }; + } catch (error) { + await this.recordCall( + input, + input.signal?.aborted ? "cancelled" : "failed", + session.sessionID, + undefined, + describe(error), + ); + throw error; } finally { input.signal?.removeEventListener("abort", abort); - active.delete(sessionID); + active.delete(activeKey); if (active.size === 0) this.active.delete(input.parentSessionID); } } - async cancel(parentSessionID: string) { + async cancel(parentSessionID: string, runID?: string) { + const sessionIDs = new Set( + [...(this.active.get(parentSessionID)?.values() ?? [])] + .filter((child) => !runID || child.runID === runID) + .map((child) => child.sessionID), + ); await Promise.all( - [...(this.active.get(parentSessionID) ?? [])].map((sessionID) => - this.client.session.abort({ sessionID }).catch(() => undefined), + [...sessionIDs].map((sessionID) => + this.client.session.abort({ sessionID }).catch(() => undefined) ), ); } + async steer(parentSessionID: string, prompt: string, runID?: string) { + const children = [...(this.active.get(parentSessionID)?.values() ?? [])] + .filter((child) => !runID || child.runID === runID) + .filter((child, index, all) => + all.findIndex((item) => item.sessionID === child.sessionID) === index + ); + if (children.length === 0) { + throw new Error(`Run session ${parentSessionID} has no active child sessions.`); + } + const responses = await Promise.all(children.map((child) => + this.client.session.prompt({ + sessionID: child.sessionID, + model: child.member.model, + agent: child.member.agent, + tools: BLOCKED_NESTED_ORCHESTRATION_TOOLS, + parts: [{ type: "text", text: prompt }], + }) + )); + responses.forEach((response) => { + if (response.error) { + throw new Error(`OpenCode steer failed: ${describe(response.error)}`); + } + if (response.data?.info?.error) { + throw new Error( + `OpenCode steer model failed: ${describe(response.data.info.error)}`, + ); + } + }); + } + + async cleanupWorkspaces(runID?: string) { + if (!this.store) return 0; + const workspaces = (await this.store.listWorkspaces(runID)).filter( + (workspace) => workspace.status !== "removed", + ); + if (workspaces.length === 0) return 0; + if (!this.client.experimental?.workspace) { + throw new Error( + "OpenCode's experimental workspace API is unavailable; no workspaces were removed.", + ); + } + for (const workspace of workspaces) { + const response = await this.client.experimental.workspace.remove({ + id: workspace.id, + }); + if (response.error) { + throw new Error( + `Could not remove workspace ${workspace.id}: ${describe(response.error)}`, + ); + } + await this.store.markWorkspaceRemoved(workspace.id); + await this.store.deleteChildSessionsForWorkspace(workspace.id); + [...this.sessions.entries()] + .filter(([, session]) => session.workspaceID === workspace.id) + .forEach(([key]) => this.sessions.delete(key)); + } + return workspaces.length; + } + async close() { await Promise.all( [...this.active.keys()].map((parentSessionID) => - this.cancel(parentSessionID), + this.cancel(parentSessionID) ), ); this.sessions.clear(); } - private async createSession(input: RunAgentInput) { + private async findOrCreateSession(key: string, input: RunAgentInput) { + const local = this.sessions.get(key); + if (local) return local; + const persisted = await this.store?.getChildSession(key); + if (persisted) { + const session = { + sessionID: persisted.session_id, + workspaceID: persisted.workspace_id ?? undefined, + }; + this.sessions.set(key, session); + return session; + } + const workspace = await this.createWorkspace(input); const response = await this.client.session.create({ parentID: input.parentSessionID, title: `Fleet: ${input.member.id} (${input.member.model.providerID}/${input.member.model.modelID})`, + workspaceID: workspace?.id, }); - if (response.error) + if (response.error) { throw new Error( `OpenCode child session failed: ${describe(response.error)}`, ); - if (!response.data?.id) + } + if (!response.data?.id) { throw new Error("OpenCode child session response had no id."); - this.sessions.set(sessionKey(input), response.data.id); - return response.data.id; + } + const session = { + sessionID: response.data.id, + workspaceID: workspace?.id, + }; + this.sessions.set(key, session); + await this.store?.saveChildSession(key, { + parentSessionID: input.parentSessionID, + memberID: input.member.id, + providerID: input.member.model.providerID, + modelID: input.member.model.modelID, + agent: input.member.agent, + sessionID: response.data.id, + workspaceID: workspace?.id, + }); + return session; + } + + private async createWorkspace(input: RunAgentInput) { + if (input.member.isolation !== "worktree") return undefined; + // Codex Delegate already owns its detached worktree. Nesting another + // OpenCode workspace would make both cleanup and file ownership ambiguous. + if (input.member.model.providerID === "codex-delegate") return undefined; + if (!this.client.experimental?.workspace) { + throw new Error( + `Fleet member ${input.member.id} requires worktree isolation, but OpenCode's experimental workspace API is unavailable. Shared-checkout fallback is forbidden.`, + ); + } + const response = await this.client.experimental.workspace.create({ + type: "worktree", + branch: null, + }); + if (response.error || !response.data?.id) { + throw new Error( + `Could not create isolated workspace for ${input.member.id}: ${describe(response.error ?? "missing workspace id")}. Shared-checkout fallback is forbidden.`, + ); + } + const now = Date.now(); + await this.store?.saveWorkspace({ + id: response.data.id, + runID: input.runID, + memberID: input.member.id, + directory: response.data.directory ?? undefined, + status: "preserved", + createdAt: now, + updatedAt: now, + }); + return response.data; + } + + private async cached(input: RunAgentInput): Promise { + if (!this.store || !input.runID || input.callIndex === undefined) { + return undefined; + } + const calls = await this.store.cachedAgentCalls(input.runID); + const prefix = calls.slice(0, input.callIndex + 1); + if (prefix.length !== input.callIndex + 1) return undefined; + if (prefix.some((call, index) => + call.call_index !== index || call.status !== "completed" || !call.output + )) return undefined; + const call = prefix[input.callIndex]!; + const expected = JSON.stringify(callOptions(input)); + if ( + call.member_id !== input.member.id || + call.prompt !== input.prompt || + call.options_json !== expected + ) return undefined; + return { + memberID: input.member.id, + sessionID: call.child_session_id ?? "", + model: input.member.model, + text: call.output!, + }; + } + + private recordCall( + input: RunAgentInput, + status: "running" | "completed" | "failed" | "cancelled", + sessionID?: string, + output?: string, + error?: string, + ) { + if (!this.store || !input.runID || input.callIndex === undefined) { + return Promise.resolve(); + } + return this.store.saveAgentCall({ + runID: input.runID, + stepID: input.stepID, + callIndex: input.callIndex, + memberID: input.member.id, + prompt: input.prompt, + options: callOptions(input), + status, + sessionID, + output, + error, + }); } } -export async function discoverFleet(client: OpenCodeClient): Promise { +export async function discoverFleet(client: AgentClient): Promise { if (!client.provider) return { leadID: "lead", members: [] }; const response = await client.provider.list(); if (response.error || !response.data) return { leadID: "lead", members: [] }; const connected = new Set( response.data.connected ?? response.data.all.map((provider) => provider.id), ); + const order = new Map( + (response.data.connected ?? []).map((providerID, index) => [providerID, index]), + ); const used = new Set(); - const members = response.data.all + const members = [...response.data.all] .filter((provider) => connected.has(provider.id)) + .sort((left, right) => + (order.get(left.id) ?? Number.MAX_SAFE_INTEGER) - + (order.get(right.id) ?? Number.MAX_SAFE_INTEGER) + ) .flatMap((provider) => { - const modelID = - response.data?.default?.[provider.id] ?? - Object.keys(provider.models)[0]; + const textModels = Object.entries(provider.models) + .filter(([, model]) => model.capabilities?.output?.text !== false); + const preferred = response.data?.default?.[provider.id]; + const modelID = preferred && textModels.some(([id]) => id === preferred) + ? preferred + : textModels[0]?.[0]; if (!modelID) return []; const base = provider.id.replace(/[^a-zA-Z0-9_-]/g, "-") || "model"; let id = base; @@ -165,15 +447,14 @@ export async function discoverFleet(client: OpenCodeClient): Promise { suffix += 1; } used.add(id); - return [ - { - id, - role: "specialist", - model: { providerID: provider.id, modelID }, - agent: "plan", - enabled: true, - } satisfies FleetMember, - ]; + return [{ + id, + role: "specialist", + model: { providerID: provider.id, modelID }, + agent: "plan", + enabled: true, + isolation: "shared" as const, + } satisfies FleetMember]; }); return { leadID: members[0]?.id ?? "lead", members }; } @@ -185,9 +466,23 @@ function sessionKey(input: RunAgentInput) { input.member.model.providerID, input.member.model.modelID, input.member.agent ?? "", + input.member.isolation ?? "shared", + input.member.isolation === "worktree" && + input.member.model.providerID !== "codex-delegate" + ? input.runID ?? "" + : "", ].join("\u0000"); } +function callOptions(input: RunAgentInput) { + return { + model: input.member.model, + agent: input.member.agent ?? null, + system: input.system ?? null, + isolation: input.member.isolation ?? "shared", + }; +} + function isTextPart(value: unknown): value is { type: "text"; text: string } { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const part = value as { type?: unknown; text?: unknown }; diff --git a/packages/opencode-multimodel/src/options.ts b/packages/opencode-multimodel/src/options.ts index 88f12f8..cb81dfa 100644 --- a/packages/opencode-multimodel/src/options.ts +++ b/packages/opencode-multimodel/src/options.ts @@ -1,67 +1,270 @@ import { COLLAB_MODES, type CollabMode, + type ComposerMode, type Fleet, type FleetMember, } from "./types.ts"; export type MultiModelOptions = { + databasePath: string; statePath?: string; fleet?: Fleet; defaultMode: CollabMode; maxWorkers: number; maxParallel: number; + composer: { + enabled: boolean; + initial: ComposerMode; + autoRoute: boolean; + }; + workflows: { + scripts: boolean; + directories: string[]; + timeoutMs: number; + maxAgentCalls: number; + }; + retention: { + runs: number; + events: number; + }; }; +export class MultiModelConfigError extends Error { + constructor(message: string) { + super(`Invalid opencode-multimodel configuration: ${message}`); + this.name = "MultiModelConfigError"; + } +} + export function parseOptions( value: Record | undefined, ): MultiModelOptions { + const input = value ?? {}; + rejectUnknown(input, [ + "databasePath", + "statePath", + "fleet", + "defaultMode", + "maxWorkers", + "maxParallel", + "composer", + "workflows", + "retention", + ]); + return { + databasePath: optionalString(input.databasePath, "databasePath") ?? + ".opencode/multimodel.sqlite", + statePath: optionalString(input.statePath, "statePath"), + fleet: optionalFleet(input.fleet), + defaultMode: optionalMode(input.defaultMode), + maxWorkers: optionalInteger(input.maxWorkers, "maxWorkers", 8, 1, 8), + maxParallel: optionalInteger( + input.maxParallel, + "maxParallel", + 3, + 1, + 6, + ), + composer: parseComposer(input.composer), + workflows: parseWorkflows(input.workflows), + retention: parseRetention(input.retention), + }; +} + +function parseComposer(value: unknown): MultiModelOptions["composer"] { + const input = optionalObject(value, "composer"); + rejectUnknown(input, ["enabled", "initial", "autoRoute"], "composer"); + const initial = input.initial ?? "single"; + if (!(["single", "team", "workflow"] as const).includes(initial as never)) { + throw new MultiModelConfigError( + 'composer.initial must be "single", "team", or "workflow".', + ); + } + return { + enabled: optionalBoolean(input.enabled, "composer.enabled", true), + initial: initial as ComposerMode, + autoRoute: optionalBoolean( + input.autoRoute, + "composer.autoRoute", + false, + ), + }; +} + +function parseWorkflows(value: unknown): MultiModelOptions["workflows"] { + const input = optionalObject(value, "workflows"); + rejectUnknown( + input, + ["scripts", "directories", "timeoutMs", "maxAgentCalls"], + "workflows", + ); + const directories = input.directories ?? [".opencode/workflows"]; + if ( + !Array.isArray(directories) || + directories.some((directory) => + typeof directory !== "string" || !directory.trim() + ) + ) { + throw new MultiModelConfigError( + "workflows.directories must be an array of non-empty strings.", + ); + } + return { + scripts: optionalBoolean(input.scripts, "workflows.scripts", false), + directories, + timeoutMs: optionalInteger( + input.timeoutMs, + "workflows.timeoutMs", + 300_000, + 100, + 3_600_000, + ), + maxAgentCalls: optionalInteger( + input.maxAgentCalls, + "workflows.maxAgentCalls", + 64, + 1, + 64, + ), + }; +} + +function parseRetention(value: unknown): MultiModelOptions["retention"] { + const input = optionalObject(value, "retention"); + rejectUnknown(input, ["runs", "events"], "retention"); return { - statePath: - typeof value?.statePath === "string" ? value.statePath : undefined, - fleet: isFleet(value?.fleet) ? value.fleet : undefined, - defaultMode: isMode(value?.defaultMode) ? value.defaultMode : "council", - maxWorkers: integer(value?.maxWorkers, 8, 1, 8), - maxParallel: integer(value?.maxParallel, 3, 1, 6), + runs: optionalInteger(input.runs, "retention.runs", 100, 1, 10_000), + events: optionalInteger( + input.events, + "retention.events", + 10_000, + 100, + 1_000_000, + ), }; } -function integer( +function optionalObject(value: unknown, path: string) { + if (value === undefined) return {} as Record; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new MultiModelConfigError(`${path} must be an object.`); + } + return value as Record; +} + +function rejectUnknown( + input: Record, + allowed: string[], + path?: string, +) { + const unknown = Object.keys(input).filter((key) => !allowed.includes(key)); + if (unknown.length === 0) return; + throw new MultiModelConfigError( + `${path ? `${path}.` : ""}${unknown[0]} is not a supported option.`, + ); +} + +function optionalString(value: unknown, path: string) { + if (value === undefined) return undefined; + if (typeof value === "string" && value.trim()) return value; + throw new MultiModelConfigError(`${path} must be a non-empty string.`); +} + +function optionalBoolean(value: unknown, path: string, fallback: boolean) { + if (value === undefined) return fallback; + if (typeof value === "boolean") return value; + throw new MultiModelConfigError(`${path} must be a boolean.`); +} + +function optionalInteger( value: unknown, + path: string, fallback: number, minimum: number, maximum: number, ) { - if (typeof value !== "number" || !Number.isInteger(value)) return fallback; - return Math.min(Math.max(value, minimum), maximum); + if (value === undefined) return fallback; + if ( + typeof value === "number" && + Number.isInteger(value) && + value >= minimum && + value <= maximum + ) return value; + throw new MultiModelConfigError( + `${path} must be an integer between ${minimum} and ${maximum}.`, + ); } -function isMode(value: unknown): value is CollabMode { - return ( +function optionalMode(value: unknown): CollabMode { + if (value === undefined) return "council"; + if ( typeof value === "string" && (COLLAB_MODES as readonly string[]).includes(value) + ) return value as CollabMode; + throw new MultiModelConfigError( + `defaultMode must be one of ${COLLAB_MODES.join(", ")}.`, ); } -function isFleet(value: unknown): value is Fleet { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const fleet = value as { leadID?: unknown; members?: unknown }; - return ( - typeof fleet.leadID === "string" && - Array.isArray(fleet.members) && - fleet.members.every(isMember) - ); +function optionalFleet(value: unknown) { + if (value === undefined) return undefined; + const input = optionalObject(value, "fleet"); + rejectUnknown(input, ["leadID", "members"], "fleet"); + const leadID = requiredString(input.leadID, "fleet.leadID"); + if (!Array.isArray(input.members)) { + throw new MultiModelConfigError( + "fleet.members must be an array.", + ); + } + const members = input.members.map(parseMember); + const ids = new Set(members.map((member) => member.id)); + if (ids.size !== members.length) { + throw new MultiModelConfigError("fleet member IDs must be unique."); + } + if (!ids.has(leadID)) { + throw new MultiModelConfigError( + `fleet.leadID ${leadID} must name a fleet member.`, + ); + } + return { leadID, members } satisfies Fleet; } -function isMember(value: unknown): value is FleetMember { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const member = value as Partial; - return ( - typeof member.id === "string" && - typeof member.role === "string" && - typeof member.enabled === "boolean" && - !!member.model && - typeof member.model.providerID === "string" && - typeof member.model.modelID === "string" +function parseMember(value: unknown, index: number): FleetMember { + const path = `fleet.members[${index}]`; + const input = optionalObject(value, path); + rejectUnknown( + input, + ["id", "role", "model", "agent", "system", "enabled", "isolation"], + path, ); + const model = optionalObject(input.model, `${path}.model`); + rejectUnknown(model, ["providerID", "modelID"], `${path}.model`); + const isolation = input.isolation ?? "shared"; + if (isolation !== "shared" && isolation !== "worktree") { + throw new MultiModelConfigError( + `${path}.isolation must be "shared" or "worktree".`, + ); + } + if (typeof input.enabled !== "boolean") { + throw new MultiModelConfigError(`${path}.enabled must be a boolean.`); + } + return { + id: requiredString(input.id, `${path}.id`), + role: requiredString(input.role, `${path}.role`), + model: { + providerID: requiredString(model.providerID, `${path}.model.providerID`), + modelID: requiredString(model.modelID, `${path}.model.modelID`), + }, + agent: optionalString(input.agent, `${path}.agent`), + system: optionalString(input.system, `${path}.system`), + enabled: input.enabled, + isolation, + }; +} + +function requiredString(value: unknown, path: string) { + const result = optionalString(value, path); + if (result !== undefined) return result; + throw new MultiModelConfigError(`${path} is required.`); } diff --git a/packages/opencode-multimodel/src/orchestration.ts b/packages/opencode-multimodel/src/orchestration.ts new file mode 100644 index 0000000..014c1ae --- /dev/null +++ b/packages/opencode-multimodel/src/orchestration.ts @@ -0,0 +1,450 @@ +import { collaborate } from "./collaborate.ts"; +import type { MultiModelOptions } from "./options.ts"; +import { runScriptWorkflow } from "./script.ts"; +import { StateStore, workflowSourceHash } from "./state.ts"; +import type { + AgentRunner, + CollaborationRun, + CollabActivity, + CollabMode, + DurableRun, + Fleet, + WorkflowDefinition, + WorkflowRun, +} from "./types.ts"; +import { isDagWorkflow, isWorkflowRun } from "./types.ts"; +import { runWorkflow } from "./workflow.ts"; + +type ActiveRun = { + controller: AbortController; + promise: Promise; + heartbeat: ReturnType; +}; + +type CollaborationStart = { + sessionID: string; + messageID?: string; + prompt: string; + mode: CollabMode; + participants?: string[]; + handoffTo?: string; + juryRounds?: 1 | 2; + background?: boolean; + signal?: AbortSignal; + onActivity?: (event: CollabActivity) => void; +}; + +type WorkflowStart = { + sessionID: string; + messageID?: string; + definition: WorkflowDefinition; + input: string; + background?: boolean; + signal?: AbortSignal; +}; + +export class RunService { + private readonly active = new Map(); + + constructor( + private readonly store: StateStore, + private readonly runner: AgentRunner, + private readonly options: MultiModelOptions, + ) {} + + async startCollaboration(input: CollaborationStart) { + const fleet = (await this.store.read()).fleet; + const participants = selectParticipants(fleet, input.participants); + const now = Date.now(); + const pending: CollaborationRun = { + id: `collab_${crypto.randomUUID()}`, + kind: "collaboration", + definition: input.mode, + sessionID: input.sessionID, + messageID: input.messageID, + input: input.prompt, + status: "pending", + mode: input.mode, + participants, + steps: participants.map((memberID) => ({ + id: memberID, + status: "pending", + memberID, + })), + background: input.background, + createdAt: now, + updatedAt: now, + }; + const run = await this.store.createRun(pending, "multimodel_collab"); + if (run.id !== pending.id) return this.resultForExisting(run); + const promise = this.executeCollaboration(pending, fleet, input); + this.track(pending.id, promise.controller, promise.run); + if (input.background) return pending; + return promise.run; + } + + async startWorkflow(input: WorkflowStart) { + const state = await this.store.read(); + const now = Date.now(); + const pending: WorkflowRun = { + id: `workflow_${crypto.randomUUID()}`, + kind: "workflow", + workflowKind: input.definition.kind ?? "dag", + definition: input.definition.name, + sessionID: input.sessionID, + messageID: input.messageID, + input: input.input, + status: "pending", + steps: isDagWorkflow(input.definition) + ? input.definition.steps.map((step) => ({ + id: step.id, + status: "pending", + memberID: step.memberID ?? state.fleet.leadID, + })) + : [], + background: input.background, + sourceHash: input.definition.kind === "script" + ? workflowSourceHash(input.definition.source) + : undefined, + createdAt: now, + updatedAt: now, + }; + const run = await this.store.createRun(pending, "multimodel_workflow"); + if (run.id !== pending.id) return this.resultForExisting(run); + const promise = this.executeWorkflow( + pending, + state.fleet, + input.definition, + input.signal, + ); + this.track(pending.id, promise.controller, promise.run); + if (input.background) return pending; + return promise.run; + } + + async resume(runID: string) { + const current = await this.store.getRun(runID); + if (!current) throw new Error(`Run ${runID} does not exist.`); + const active = this.active.get(runID); + if (active) { + await this.store.setRunControl(runID, "run"); + return current; + } + if (current.kind === "collaboration") { + await this.claimResumeLease(runID); + const state = await this.store.read(); + await this.store.setRunControl(runID, "run"); + const promise = this.executeCollaboration(current, state.fleet, { + sessionID: current.sessionID, + messageID: current.messageID, + prompt: current.input, + mode: current.mode, + participants: current.participants, + background: current.background, + }); + this.track(runID, promise.controller, promise.run); + return current; + } + const state = await this.store.read(); + const definition = state.workflows.find((item) => + item.name === current.definition + ); + if (!definition) { + throw new Error(`Workflow ${current.definition} no longer exists.`); + } + if ( + definition.kind === "script" && + current.sourceHash !== workflowSourceHash(definition.source) + ) { + throw new Error( + `Workflow ${definition.name} changed since this run. Start a new run and approve its new source hash.`, + ); + } + await this.claimResumeLease(runID); + await this.store.setRunControl(runID, "run"); + const promise = this.executeWorkflow(current, state.fleet, definition); + this.track(runID, promise.controller, promise.run); + return current; + } + + async pause(runID: string) { + await this.requireRun(runID); + await this.store.setRunControl(runID, "pause"); + } + + async stop(runID: string) { + const run = await this.requireRun(runID); + await this.store.setRunControl(runID, "stop"); + this.active.get(runID)?.controller.abort("Run stopped by user."); + await this.runner.cancel?.(run.sessionID, run.id); + } + + async cancel(runID: string) { + const run = await this.requireRun(runID); + await this.store.setRunControl(runID, "stop"); + this.active.get(runID)?.controller.abort("Run cancelled by user."); + await this.runner.cancel?.(run.sessionID, run.id); + } + + async steer(runID: string, prompt: string) { + const run = await this.requireRun(runID); + await this.store.appendEvent(runID, "run.steered", { prompt }); + await this.runner.steer?.(run.sessionID, prompt, run.id); + } + + async restartAgent(runID: string, stepID: string) { + const run = await this.requireRun(runID); + if (!isWorkflowRun(run)) { + throw new Error("restart-agent is only available for workflow runs."); + } + const index = run.steps.findIndex((step) => step.id === stepID); + if (index === -1) throw new Error(`Run ${runID} has no step ${stepID}.`); + run.steps.slice(index).forEach((step) => { + step.status = "pending"; + step.output = undefined; + step.error = undefined; + step.startedAt = undefined; + step.completedAt = undefined; + }); + run.status = "interrupted"; + run.error = undefined; + await this.store.deleteAgentCallsFrom(runID, index); + await this.store.saveRun(run); + await this.store.appendEvent(runID, "agent.restart.requested", { stepID }); + return this.resume(runID); + } + + async cleanupWorkspaces(runID?: string) { + return this.runner.cleanupWorkspaces?.(runID) ?? 0; + } + + async dispose() { + this.active.forEach((run) => { + clearInterval(run.heartbeat); + run.controller.abort("Plugin disposed."); + }); + await Promise.allSettled( + [...this.active.values()].map((run) => run.promise), + ); + this.active.clear(); + } + + private executeCollaboration( + run: CollaborationRun, + fleet: Fleet, + input: CollaborationStart, + ) { + const controller = linkedController(input.signal); + const execute = (async () => { + run.status = "running"; + run.error = undefined; + await this.store.saveRun(run); + await this.waitUntilRunnable(run, controller.signal); + try { + const result = await collaborate( + this.runner, + fleet, + run.sessionID, + run.input, + { + mode: run.mode, + participants: run.participants, + handoffTo: input.handoffTo, + juryRounds: input.juryRounds, + maxWorkers: this.options.maxWorkers, + maxParallel: this.options.maxParallel, + signal: controller.signal, + runID: run.id, + onActivity: (event) => { + input.onActivity?.(event); + void this.store.appendEvent(run.id, "collaboration.activity", event); + }, + }, + ); + run.status = "completed"; + run.final = result.final.text; + run.participants = result.participants; + run.steps = result.participants.map((memberID) => { + const reply = [...result.replies] + .reverse() + .find((item) => item.memberID === memberID); + return { + id: memberID, + status: reply?.error ? "failed" as const : "completed" as const, + memberID, + output: reply?.text, + error: reply?.error, + completedAt: Date.now(), + }; + }); + } catch (error) { + run.status = controller.signal.aborted + ? abortedStatus(controller.signal) + : "failed"; + run.error = error instanceof Error ? error.message : String(error); + } + run.updatedAt = Date.now(); + await this.store.saveRun(run); + return run; + })(); + return { controller, run: execute }; + } + + private executeWorkflow( + run: WorkflowRun, + fleet: Fleet, + definition: WorkflowDefinition, + signal?: AbortSignal, + ) { + const controller = linkedController(signal); + const timeout = setTimeout( + () => controller.abort( + `Workflow timed out after ${this.options.workflows.timeoutMs} ms.`, + ), + this.options.workflows.timeoutMs, + ); + const common = { + signal: controller.signal, + run, + runID: run.id, + messageID: run.messageID, + background: run.background, + maxAgentCalls: this.options.workflows.maxAgentCalls, + maxParallel: this.options.maxParallel, + timeoutMs: this.options.workflows.timeoutMs, + beforeStep: (snapshot: WorkflowRun) => + this.waitUntilRunnable(snapshot, controller.signal), + onUpdate: (snapshot: WorkflowRun) => + this.store.saveRun(snapshot).then(() => undefined), + }; + const execution = definition.kind === "script" + ? runScriptWorkflow( + this.runner, + fleet, + run.sessionID, + definition, + run.input, + common, + ) + : runWorkflow( + this.runner, + fleet, + run.sessionID, + definition, + run.input, + common, + ); + const execute = execution.then(async (finished) => { + if (controller.signal.aborted && finished.status === "cancelled") { + const reason = String(controller.signal.reason); + finished.status = reason.toLowerCase().includes("timed out") + ? "failed" + : abortedStatus(controller.signal); + if (finished.status === "failed") finished.error = reason; + await this.store.saveRun(finished); + } + return finished; + }).catch(async (error) => { + run.status = controller.signal.aborted + ? abortedStatus(controller.signal) + : "failed"; + run.error = error instanceof Error ? error.message : String(error); + run.updatedAt = Date.now(); + await this.store.saveRun(run); + return run; + }).finally(() => clearTimeout(timeout)); + return { controller, run: execute }; + } + + private async waitUntilRunnable(run: DurableRun, signal: AbortSignal) { + for (;;) { + signal.throwIfAborted(); + const control = await this.store.getRunControl(run.id); + if (control === "stop") { + throw new DOMException("Run stopped by user.", "AbortError"); + } + if (control === "run") { + if (run.status === "paused") { + run.status = "running"; + await this.store.saveRun(run); + } + return; + } + if (run.status !== "paused") { + run.status = "paused"; + await this.store.saveRun(run); + } + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, 250); + signal.addEventListener("abort", () => { + clearTimeout(timer); + reject(signal.reason); + }, { once: true }); + }); + } + } + + private track( + runID: string, + controller: AbortController, + promise: Promise, + ) { + const heartbeat = setInterval( + () => void this.store.renewLease(runID), + 10_000, + ); + this.active.set(runID, { controller, promise, heartbeat }); + void promise + .finally(() => { + if (this.active.get(runID)?.promise !== promise) return; + clearInterval(heartbeat); + this.active.delete(runID); + }) + .catch(() => undefined); + } + + private async requireRun(runID: string) { + const run = await this.store.getRun(runID); + if (!run) throw new Error(`Run ${runID} does not exist.`); + return run; + } + + private async claimResumeLease(runID: string) { + if (await this.store.claimLease(runID)) return; + throw new Error(`Run ${runID} is active in another plugin process.`); + } + + private resultForExisting(run: DurableRun) { + if (run.status === "interrupted") return run; + return run; + } +} + +function selectParticipants(fleet: Fleet, requested?: string[]) { + const enabled = fleet.members.filter((member) => member.enabled); + if (!requested?.length) return enabled.map((member) => member.id); + const selected = requested.filter((id) => + enabled.some((member) => member.id === id) + ); + if (selected.length !== requested.length) { + const missing = requested.filter((id) => !selected.includes(id)); + throw new Error(`Missing or disabled fleet members: ${missing.join(", ")}.`); + } + if (!selected.includes(fleet.leadID)) selected.unshift(fleet.leadID); + return selected; +} + +function linkedController(signal?: AbortSignal) { + const controller = new AbortController(); + if (signal?.aborted) controller.abort(signal.reason); + signal?.addEventListener("abort", () => controller.abort(signal.reason), { + once: true, + }); + return controller; +} + +function abortedStatus(signal: AbortSignal): "cancelled" | "stopped" { + return String(signal.reason).toLowerCase().includes("stopped") + ? "stopped" + : "cancelled"; +} diff --git a/packages/opencode-multimodel/src/script.ts b/packages/opencode-multimodel/src/script.ts new file mode 100644 index 0000000..07aff4c --- /dev/null +++ b/packages/opencode-multimodel/src/script.ts @@ -0,0 +1,611 @@ +import { mapLimit } from "./concurrency.ts"; +import { collaborationSystem } from "./prompts.ts"; +import { workflowSourceHash } from "./state.ts"; +import type { + AgentRunner, + Fleet, + ScriptWorkflowDefinition, + WorkflowRun, + WorkflowRunOptions, +} from "./types.ts"; + +const MAX_SOURCE_BYTES = 500_000; +const MAX_PARALLEL = 6; +const ALLOWED_CALLS = new Set([ + "args", + "agent", + "parallel", + "pipeline", + "phase", + "log", +]); +const FORBIDDEN = [ + /\bimport\b/, + /\brequire\b/, + /\bprocess\b/, + /\bBun\b/, + /\bglobalThis\b/, + /\bglobal\b/, + /\bDeno\b/, + /\bfetch\b/, + /\bXMLHttpRequest\b/, + /\bWebSocket\b/, + /\beval\b/, + /\bFunction\b/, + /\bWebAssembly\b/, + /\b__dirname\b/, + /\b__filename\b/, + /\bconstructor\b/, + /\bprototype\b/, + /\b__proto__\b/, +]; + +type ScriptNode = + | { type: "literal"; value: string | number | boolean | null } + | { type: "identifier"; name: string } + | { type: "member"; object: ScriptNode; property: string } + | { type: "array"; items: ScriptNode[] } + | { type: "object"; entries: Array<[string, ScriptNode]> } + | { type: "call"; name: string; args: ScriptNode[] }; + +type Token = { + type: "identifier" | "string" | "number" | "punctuation" | "eof"; + value: string; + offset: number; +}; + +export class ScriptWorkflowError extends Error { + constructor(message: string) { + super(message); + this.name = "ScriptWorkflowError"; + } +} + +export function validateWorkflowScript(source: string) { + if (!source.trim()) throw new ScriptWorkflowError("Workflow source is empty."); + if (new TextEncoder().encode(source).byteLength > MAX_SOURCE_BYTES) { + throw new ScriptWorkflowError( + `Workflow source exceeds ${MAX_SOURCE_BYTES} bytes.`, + ); + } + const forbidden = FORBIDDEN.find((pattern) => pattern.test(source)); + if (forbidden) { + throw new ScriptWorkflowError( + `Forbidden workflow capability ${forbidden}. Scripts may only use args, agent, parallel, pipeline, phase, and log.`, + ); + } + const expression = scriptExpression(source); + const node = new Parser(tokenize(expression)).parse(); + if (!containsAgent(node)) { + throw new ScriptWorkflowError("Workflow must call agent()."); + } + return { node, sourceHash: workflowSourceHash(source) }; +} + +export async function runScriptWorkflow( + runner: AgentRunner, + fleet: Fleet, + parentSessionID: string, + definition: ScriptWorkflowDefinition, + input: string, + options: WorkflowRunOptions = {}, +) { + const validated = validateWorkflowScript(definition.source); + const lead = fleet.members.find( + (member) => member.id === fleet.leadID && member.enabled, + ); + if (!lead) throw new Error(`Fleet lead ${fleet.leadID} is missing or disabled.`); + const createdAt = Date.now(); + const run: WorkflowRun = options.run + ? structuredClone(options.run) + : { + id: options.runID ?? `workflow_${crypto.randomUUID()}`, + kind: "workflow", + workflowKind: "script", + definition: definition.name, + sessionID: parentSessionID, + messageID: options.messageID, + input, + status: "pending", + steps: [], + background: options.background, + sourceHash: validated.sourceHash, + createdAt, + updatedAt: createdAt, + }; + run.status = "running"; + run.error = undefined; + run.sourceHash = validated.sourceHash; + await publish(run, options); + const timeout = AbortSignal.timeout(options.timeoutMs ?? 300_000); + const signal = options.signal + ? AbortSignal.any([options.signal, timeout]) + : timeout; + const state = { + calls: 0, + indexes: indexAgentNodes(validated.node), + logs: [] as string[], + previous: undefined as unknown, + }; + + try { + const value = await evaluate(validated.node, { + runner, + fleet, + lead, + parentSessionID, + definition, + run, + options, + signal, + input, + state, + }); + run.status = "completed"; + run.final = outputText(value) ?? + [...run.steps].reverse().find((step) => step.output)?.output ?? + state.logs.at(-1) ?? + "Workflow completed."; + await publish(run, options); + return run; + } catch (error) { + const cancelled = signal.aborted; + run.status = cancelled ? "cancelled" : "failed"; + run.error = timeout.aborted + ? `Workflow exceeded ${options.timeoutMs ?? 300_000} ms.` + : error instanceof Error ? error.message : String(error); + run.steps + .filter((step) => step.status === "running" || step.status === "pending") + .forEach((step) => { + step.status = "cancelled"; + step.error = run.error; + step.completedAt = Date.now(); + }); + await publish(run, options); + if (cancelled) await runner.cancel?.(parentSessionID, run.id); + return run; + } +} + +type EvaluationContext = { + runner: AgentRunner; + fleet: Fleet; + lead: Fleet["members"][number]; + parentSessionID: string; + definition: ScriptWorkflowDefinition; + run: WorkflowRun; + options: WorkflowRunOptions; + signal: AbortSignal; + input: string; + state: { + calls: number; + indexes: Map; + logs: string[]; + previous: unknown; + }; +}; + +async function evaluate( + node: ScriptNode, + context: EvaluationContext, +): Promise { + context.signal.throwIfAborted(); + if (node.type === "literal") return node.value; + if (node.type === "identifier") { + if (node.name === "args") { + return { input: context.input, previous: context.state.previous }; + } + throw new ScriptWorkflowError(`Unknown identifier ${node.name}.`); + } + if (node.type === "member") { + const value = await evaluate(node.object, context); + if (!isRecord(value)) return undefined; + return value[node.property]; + } + if (node.type === "array") { + const output = []; + for (const item of node.items) output.push(await evaluate(item, context)); + return output; + } + if (node.type === "object") { + const output: Record = {}; + for (const [key, value] of node.entries) { + output[key] = await evaluate(value, context); + } + return output; + } + if (node.name === "parallel") { + const list = requireArrayNode(node.args[0], "parallel"); + const parallel = Math.min( + MAX_PARALLEL, + Math.max(1, context.options.maxParallel ?? MAX_PARALLEL), + ); + const values = await mapLimit(list.items, parallel, (item) => + evaluate(item, context) + ); + context.state.previous = values; + return values; + } + if (node.name === "pipeline") { + const list = requireArrayNode(node.args[0], "pipeline"); + const values = []; + for (const item of list.items) { + const value = await evaluate(item, context); + values.push(value); + context.state.previous = value; + } + return values; + } + if (node.name === "phase") { + const label = String(await evaluateRequired(node.args[0], context, "phase")); + context.state.logs.push(`phase:${label}`); + if (!node.args[1]) return label; + return evaluate(node.args[1], context); + } + if (node.name === "log") { + const value = await evaluateRequired(node.args[0], context, "log"); + context.state.logs.push(outputText(value) ?? String(value)); + return value; + } + if (node.name === "args") { + const key = node.args[0] + ? String(await evaluate(node.args[0], context)) + : "input"; + if (key === "input") return context.input; + if (key === "previous") return context.state.previous; + return undefined; + } + if (node.name !== "agent") { + throw new ScriptWorkflowError(`Unsupported call ${node.name}().`); + } + return runAgent(node, context); +} + +async function runAgent( + node: Extract, + context: EvaluationContext, +) { + const request = await evaluateRequired(node.args[0], context, "agent"); + const explicit = node.args[1] ? await evaluate(node.args[1], context) : {}; + const options = isRecord(request) + ? request + : isRecord(explicit) ? explicit : {}; + const prompt = isRecord(request) + ? typeof request.prompt === "string" ? request.prompt : "" + : String(request); + if (!prompt.trim()) throw new ScriptWorkflowError("agent() requires a prompt."); + const maxCalls = Math.min(64, context.options.maxAgentCalls ?? 64); + if (context.state.calls >= maxCalls) { + throw new ScriptWorkflowError( + `Workflow exceeded its ${maxCalls} agent-call limit.`, + ); + } + context.state.calls += 1; + const callIndex = context.state.indexes.get(node)!; + const stepID = `agent_${callIndex + 1}`; + const memberID = typeof options.memberID === "string" + ? options.memberID + : typeof options.agentId === "string" ? options.agentId : undefined; + const member = memberID + ? context.fleet.members.find((item) => item.id === memberID && item.enabled) + : context.lead; + if (!member) { + throw new ScriptWorkflowError( + `agent() selected missing or disabled fleet member ${memberID}.`, + ); + } + await context.options.beforeStep?.(structuredClone(context.run)); + context.signal.throwIfAborted(); + const step = context.run.steps[callIndex] ?? { + id: stepID, + status: "pending" as const, + memberID: member.id, + }; + context.run.steps[callIndex] = step; + step.id = stepID; + step.memberID = member.id; + step.status = "running"; + step.error = undefined; + step.startedAt = Date.now(); + await publish(context.run, context.options); + try { + const reply = await context.runner.run({ + parentSessionID: context.parentSessionID, + member, + prompt, + system: [ + collaborationSystem( + member, + context.lead, + context.fleet.members.filter((item) => item.enabled), + ), + `You are executing confined script workflow **${context.definition.name}**, call ${callIndex + 1}.`, + ].join("\n\n"), + signal: context.signal, + runID: context.run.id, + stepID, + callIndex, + }); + step.status = "completed"; + step.output = reply.text; + step.completedAt = Date.now(); + await publish(context.run, context.options); + return reply.text; + } catch (error) { + step.status = context.signal.aborted ? "cancelled" : "failed"; + step.error = error instanceof Error ? error.message : String(error); + step.completedAt = Date.now(); + await publish(context.run, context.options); + throw error; + } +} + +class Parser { + private index = 0; + + constructor(private readonly tokens: Token[]) {} + + parse() { + const node = this.expression(); + if (this.peek().value === ";") this.index += 1; + if (this.peek().type !== "eof") this.fail("Unexpected trailing input"); + return node; + } + + private expression(): ScriptNode { + let node = this.primary(); + while (this.peek().value === ".") { + this.index += 1; + const property = this.consume("identifier").value; + node = { type: "member", object: node, property }; + } + if (this.peek().value !== "(") return node; + if (node.type !== "identifier" || !ALLOWED_CALLS.has(node.name)) { + this.fail("Only confined workflow functions may be called"); + } + this.index += 1; + const args: ScriptNode[] = []; + while (this.peek().value !== ")") { + args.push(this.expression()); + if (this.peek().value !== ",") break; + this.index += 1; + } + this.expect(")"); + return { type: "call", name: node.name, args }; + } + + private primary(): ScriptNode { + const token = this.peek(); + if (token.type === "string") { + this.index += 1; + return { type: "literal", value: token.value }; + } + if (token.type === "number") { + this.index += 1; + return { type: "literal", value: Number(token.value) }; + } + if (token.type === "identifier") { + this.index += 1; + if (token.value === "true" || token.value === "false") { + return { type: "literal", value: token.value === "true" }; + } + if (token.value === "null") return { type: "literal", value: null }; + if (token.value !== "args" && !ALLOWED_CALLS.has(token.value)) { + this.fail(`Unknown identifier ${token.value}`, token); + } + return { type: "identifier", name: token.value }; + } + if (token.value === "[") return this.array(); + if (token.value === "{") return this.object(); + if (token.value === "(") { + this.index += 1; + const node = this.expression(); + this.expect(")"); + return node; + } + this.fail("Expected a workflow expression", token); + } + + private array(): ScriptNode { + this.expect("["); + const items: ScriptNode[] = []; + while (this.peek().value !== "]") { + items.push(this.expression()); + if (this.peek().value !== ",") break; + this.index += 1; + } + this.expect("]"); + return { type: "array", items }; + } + + private object(): ScriptNode { + this.expect("{"); + const entries: Array<[string, ScriptNode]> = []; + while (this.peek().value !== "}") { + const key = this.peek(); + if (key.type !== "identifier" && key.type !== "string") { + this.fail("Expected an object key", key); + } + this.index += 1; + this.expect(":"); + entries.push([key.value, this.expression()]); + if (this.peek().value !== ",") break; + this.index += 1; + } + this.expect("}"); + return { type: "object", entries }; + } + + private peek() { + return this.tokens[this.index]!; + } + + private consume(type: Token["type"]) { + const token = this.peek(); + if (token.type !== type) this.fail(`Expected ${type}`, token); + this.index += 1; + return token; + } + + private expect(value: string) { + const token = this.peek(); + if (token.value !== value) this.fail(`Expected ${value}`, token); + this.index += 1; + } + + private fail(message: string, token = this.peek()): never { + throw new ScriptWorkflowError(`${message} at offset ${token.offset}.`); + } +} + +function tokenize(source: string) { + const tokens: Token[] = []; + let index = 0; + while (index < source.length) { + const char = source[index]!; + if (/\s/.test(char)) { + index += 1; + continue; + } + if (char === "/" && source[index + 1] === "/") { + index = source.indexOf("\n", index + 2); + if (index === -1) index = source.length; + continue; + } + if (char === "/" && source[index + 1] === "*") { + const end = source.indexOf("*/", index + 2); + if (end === -1) throw new ScriptWorkflowError("Unterminated comment."); + index = end + 2; + continue; + } + if (char === '"' || char === "'") { + const start = index++; + let value = ""; + while (index < source.length && source[index] !== char) { + if (source[index] === "\\") { + index += 1; + const escaped = source[index]; + if (escaped === undefined) break; + value += ({ n: "\n", r: "\r", t: "\t" } as Record)[escaped] ?? escaped; + index += 1; + continue; + } + value += source[index++]; + } + if (source[index] !== char) { + throw new ScriptWorkflowError(`Unterminated string at offset ${start}.`); + } + index += 1; + tokens.push({ type: "string", value, offset: start }); + continue; + } + if (/[0-9-]/.test(char)) { + const start = index; + index += 1; + while (/[0-9.eE+_-]/.test(source[index] ?? "")) index += 1; + const value = source.slice(start, index); + if (!Number.isFinite(Number(value))) { + throw new ScriptWorkflowError(`Invalid number ${value}.`); + } + tokens.push({ type: "number", value, offset: start }); + continue; + } + if (/[a-zA-Z_$]/.test(char)) { + const start = index; + index += 1; + while (/[a-zA-Z0-9_$-]/.test(source[index] ?? "")) index += 1; + tokens.push({ + type: "identifier", + value: source.slice(start, index), + offset: start, + }); + continue; + } + if ("()[]{},:.;".includes(char)) { + tokens.push({ type: "punctuation", value: char, offset: index++ }); + continue; + } + throw new ScriptWorkflowError( + `Unsupported syntax ${JSON.stringify(char)} at offset ${index}.`, + ); + } + tokens.push({ type: "eof", value: "", offset: source.length }); + return tokens; +} + +function scriptExpression(source: string) { + const withoutMeta = source + .replace(/export\s+const\s+meta\s*=\s*\{[\s\S]*?\}\s*;?/m, "") + .trim(); + const exported = withoutMeta.replace(/^export\s+default\s+/, "").trim(); + const arrow = exported.indexOf("=>"); + if (arrow === -1) return exported; + const body = exported.slice(arrow + 2).trim(); + if (!body.startsWith("{")) return body; + const match = body.match(/^\{\s*return\s+([\s\S]*?);?\s*\}\s*;?$/); + if (!match) { + throw new ScriptWorkflowError( + "Confined function bodies may contain only one return expression.", + ); + } + return match[1]!; +} + +function containsAgent(node: ScriptNode): boolean { + if (node.type === "call") { + return node.name === "agent" || node.args.some(containsAgent); + } + if (node.type === "array") return node.items.some(containsAgent); + if (node.type === "object") { + return node.entries.some(([, value]) => containsAgent(value)); + } + if (node.type === "member") return containsAgent(node.object); + return false; +} + +function indexAgentNodes(node: ScriptNode) { + const indexes = new Map(); + const visit = (current: ScriptNode) => { + if (current.type === "call") { + if (current.name === "agent") indexes.set(current, indexes.size); + current.args.forEach(visit); + return; + } + if (current.type === "array") current.items.forEach(visit); + if (current.type === "object") { + current.entries.forEach(([, value]) => visit(value)); + } + if (current.type === "member") visit(current.object); + }; + visit(node); + return indexes; +} + +function requireArrayNode(node: ScriptNode | undefined, name: string) { + if (node?.type === "array") return node; + throw new ScriptWorkflowError(`${name}() requires an array literal.`); +} + +async function evaluateRequired( + node: ScriptNode | undefined, + context: EvaluationContext, + name: string, +) { + if (!node) throw new ScriptWorkflowError(`${name}() requires an argument.`); + return evaluate(node, context); +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function outputText(value: unknown) { + if (typeof value === "string") return value; + if (value === undefined) return undefined; + return JSON.stringify(value, null, 2); +} + +async function publish(run: WorkflowRun, options: WorkflowRunOptions) { + run.updatedAt = Date.now(); + await options.onUpdate?.(structuredClone(run)); +} diff --git a/packages/opencode-multimodel/src/server.ts b/packages/opencode-multimodel/src/server.ts index 79210d5..43446fc 100644 --- a/packages/opencode-multimodel/src/server.ts +++ b/packages/opencode-multimodel/src/server.ts @@ -1,27 +1,63 @@ +import { isAbsolute, resolve } from "node:path"; import { tool, type Plugin, type PluginModule } from "@opencode-ai/plugin"; -import { collaborate } from "./collaborate.ts"; +import { createOpencodeClient } from "@opencode-ai/sdk/v2"; import { + adaptPluginClient, asOpenCodeClient, discoverFleet, OpenCodeAgentRunner, } from "./opencode.ts"; +import { RunService } from "./orchestration.ts"; import { parseOptions } from "./options.ts"; -import { defaultStatePath, StateStore } from "./state.ts"; +import { validateWorkflowScript } from "./script.ts"; +import { + resolveDatabasePath, + StateStore, + workflowSourceHash, +} from "./state.ts"; import { COLLAB_MODES, + isDagWorkflow, + isWorkflowRun, type CollabMode, + type DurableRun, type WorkflowDefinition, } from "./types.ts"; -import { runWorkflow, validateWorkflow } from "./workflow.ts"; +import { validateWorkflow } from "./workflow.ts"; +import { + loadWorkflowDirectories, + parseWorkflowDefinition, +} from "./workflow-files.ts"; const server: Plugin = async (input, rawOptions) => { const options = parseOptions(rawOptions); - const client = asOpenCodeClient(input.client); + const client = input.client + ? adaptPluginClient(input.client) + : asOpenCodeClient(createOpencodeClient({ + baseUrl: input.serverUrl?.toString(), + directory: input.directory, + })); const store = new StateStore( - options.statePath ?? defaultStatePath(input.directory), + resolveDatabasePath(input.directory, options.databasePath), + { + legacyPath: options.statePath + ? absolute(input.directory, options.statePath) + : undefined, + retention: options.retention, + }, ); - await store.initializeFleet(options.fleet ?? (await discoverFleet(client))); - const runner = new OpenCodeAgentRunner(client); + await store.initializeFleet(options.fleet ?? { leadID: "lead", members: [] }); + await loadWorkflowDirectories(store, input.directory, options.workflows); + const runner = new OpenCodeAgentRunner(client, store); + const runs = new RunService(store, runner, options); + const readState = async (discover = false) => { + const state = await store.read(); + if (!discover || options.fleet || state.fleet.members.length > 0) { + return state; + } + await store.initializeFleet(await discoverFleet(client)); + return store.read(); + }; return { async config(config) { @@ -38,10 +74,21 @@ const server: Plugin = async (input, rawOptions) => { }; config.command.collab ??= { description: "Run a multi-model collaboration", - template: `Call multimodel_collab for this request. Use mode ${options.defaultMode} unless the first argument names a mode. Request: $ARGUMENTS`, + template: `Act only as a deterministic command adapter. Do not answer the user's request yourself. Call multimodel_collab exactly once and return its tool result verbatim. + +The raw command arguments are between the delimiters below: + +$ARGUMENTS + + +Parsing rules: +1. Read the first whitespace-delimited token. +2. If it is one of ${COLLAB_MODES.join(", ")}, pass that exact token as mode and copy every character after the following whitespace into prompt. +3. Otherwise pass mode=${options.defaultMode} and copy all raw command arguments into prompt. +4. The prompt is required. If text remains after a recognized mode, the tool's prompt argument MUST contain that complete text verbatim and MUST NOT be empty.`, }; config.command.workflow ??= { - description: "Run a declarative multi-model workflow", + description: "Run a durable multi-model workflow", template: "Call multimodel_workflow with action=run. Treat the first argument as name and the remaining text as input: $ARGUMENTS", }; @@ -54,63 +101,95 @@ const server: Plugin = async (input, rawOptions) => { tool: { multimodel_fleet: tool({ description: - "List or configure the OpenCode multi-model fleet and select its lead.", + "List or configure the durable OpenCode multi-model fleet, its lead, enabled state, and isolation.", args: { - action: tool.schema.enum(["list", "set-lead", "add", "remove"]), + action: tool.schema.enum([ + "list", + "set-lead", + "add", + "update", + "remove", + "enable", + "disable", + ]), memberID: tool.schema.string().optional(), role: tool.schema.string().optional(), providerID: tool.schema.string().optional(), modelID: tool.schema.string().optional(), agent: tool.schema.string().optional(), + isolation: tool.schema.enum(["shared", "worktree"]).optional(), }, - async execute(args) { + async execute(args, context) { + if (args.action !== "list") { + const memberID = requireText(args.memberID, "memberID"); + const pattern = `${args.action}:${memberID}`; + await context.ask({ + permission: "multimodel.fleet", + patterns: [pattern], + always: [pattern], + metadata: { action: args.action, memberID }, + }); + } if (args.action === "set-lead") { - if (!args.memberID) - throw new Error("memberID is required for set-lead."); - await store.setLead(args.memberID); + await store.setLead(requireText(args.memberID, "memberID")); } if (args.action === "remove") { - if (!args.memberID) - throw new Error("memberID is required for remove."); - await store.removeMember(args.memberID); + await store.removeMember(requireText(args.memberID, "memberID")); } - if (args.action === "add") { - if (!args.memberID || !args.providerID || !args.modelID) { - throw new Error( - "memberID, providerID and modelID are required for add.", - ); + if (args.action === "enable" || args.action === "disable") { + await store.enableMember( + requireText(args.memberID, "memberID"), + args.action === "enable", + ); + } + if (args.action === "add" || args.action === "update") { + const state = await readState(); + const memberID = requireText(args.memberID, "memberID"); + const existing = state.fleet.members.find((member) => + member.id === memberID + ); + if (args.action === "update" && !existing) { + throw new Error(`Fleet member ${memberID} does not exist.`); } await store.upsertMember({ - id: args.memberID, - role: args.role ?? "specialist", - model: { providerID: args.providerID, modelID: args.modelID }, - agent: args.agent, - enabled: true, + id: memberID, + role: args.role ?? existing?.role ?? "specialist", + model: { + providerID: args.providerID ?? existing?.model.providerID ?? + requireText(args.providerID, "providerID"), + modelID: args.modelID ?? existing?.model.modelID ?? + requireText(args.modelID, "modelID"), + }, + agent: args.agent ?? existing?.agent, + enabled: existing?.enabled ?? true, + isolation: args.isolation ?? existing?.isolation ?? "shared", }); } - return formatFleet((await store.read()).fleet); + return formatFleet((await readState(args.action === "list")).fleet); }, }), multimodel_collab: tool({ description: - "Run several OpenCode provider models concurrently using a Poly-derived collaboration mode, with a selected lead.", + "Start a foreground or background multi-model collaboration. Always pass the complete user request in prompt; never omit or answer it outside the tool.", args: { - prompt: tool.schema - .string() - .describe("The task or question for the fleet"), + prompt: tool.schema.string().min(1).describe( + "Required complete collaboration request, copied verbatim from the command after its optional mode token. Never pass an empty string.", + ), mode: tool.schema.enum(COLLAB_MODES).optional(), participants: tool.schema.array(tool.schema.string()).optional(), handoffTo: tool.schema.string().optional(), juryRounds: tool.schema .union([tool.schema.literal(1), tool.schema.literal(2)]) .optional(), + background: tool.schema.boolean().optional(), }, async execute(args, context) { - const state = await store.read(); - if (state.fleet.members.length === 0) + const state = await readState(true); + if (state.fleet.members.length === 0) { throw new Error( "The fleet is empty. Add a model with multimodel_fleet first.", ); + } const mode = (args.mode ?? options.defaultMode) as CollabMode; await context.ask({ permission: "multimodel.collab", @@ -118,111 +197,194 @@ const server: Plugin = async (input, rawOptions) => { always: ["*"], metadata: { mode, - participants: - args.participants ?? + participants: args.participants ?? state.fleet.members.map((member) => member.id), + background: args.background === true, }, }); - const result = await collaborate( - runner, - state.fleet, - context.sessionID, - args.prompt, - { - mode, - participants: args.participants, - handoffTo: args.handoffTo, - juryRounds: args.juryRounds, - maxWorkers: options.maxWorkers, - maxParallel: options.maxParallel, - signal: context.abort, - onActivity(event) { - context.metadata({ - title: `${event.memberID}: ${event.phase}`, - metadata: event, - }); - }, - }, - ); - return { - title: `${result.mode}: ${result.participants.join(", ")}`, - output: result.final.text, - metadata: { - mode: result.mode, - leadID: result.leadID, - participants: result.participants, - sessions: Object.fromEntries( - result.replies - .filter((reply) => reply.sessionID) - .map((reply) => [reply.memberID, reply.sessionID]), - ), - majority: result.jury?.majority, + const run = await runs.startCollaboration({ + sessionID: context.sessionID, + messageID: context.messageID, + prompt: args.prompt, + mode, + participants: args.participants, + handoffTo: args.handoffTo, + juryRounds: args.juryRounds, + background: args.background, + signal: args.background ? undefined : context.abort, + onActivity(event) { + context.metadata({ + title: `${event.memberID}: ${event.phase}`, + metadata: { runID: undefined, ...event }, + }); }, - }; + }); + return runOutput(run); + }, + }), + multimodel_run: tool({ + description: + "Inspect, cancel, steer, resume, or clean up workspaces for durable multi-model runs.", + args: { + action: tool.schema.enum([ + "list", + "get", + "cancel", + "steer", + "resume", + "cleanup-workspaces", + ]), + runID: tool.schema.string().optional(), + prompt: tool.schema.string().optional(), + }, + async execute(args, context) { + if (args.action === "list") { + return JSON.stringify(await store.listRuns(100), null, 2); + } + if (args.action === "cleanup-workspaces") { + const count = await runs.cleanupWorkspaces(args.runID); + return `Removed ${count} preserved workspace${count === 1 ? "" : "s"}.`; + } + const runID = requireText(args.runID, "runID"); + if (args.action === "get") { + const run = await store.getRun(runID); + if (!run) throw new Error(`Run ${runID} does not exist.`); + return JSON.stringify(run, null, 2); + } + await context.ask({ + permission: `multimodel.run.${args.action}`, + patterns: [runID], + always: [runID], + metadata: { runID }, + }); + if (args.action === "cancel") await runs.cancel(runID); + if (args.action === "resume") await runs.resume(runID); + if (args.action === "steer") { + await runs.steer(runID, requireText(args.prompt, "prompt")); + } + return runOutput((await store.getRun(runID))!); }, }), multimodel_workflow: tool({ description: - "Save, list, run, or inspect safe declarative multi-model DAG workflows.", + "Save, inspect, run, pause, resume, stop, or restart durable DAG and confined script workflows.", args: { - action: tool.schema.enum(["list", "save", "run", "history"]), + action: tool.schema.enum([ + "list", + "save", + "inspect", + "run", + "history", + "pause", + "resume", + "stop", + "restart-agent", + ]), name: tool.schema.string().optional(), + runID: tool.schema.string().optional(), + stepID: tool.schema.string().optional(), input: tool.schema.string().optional(), + background: tool.schema.boolean().optional(), definition: tool.schema .string() .optional() - .describe("Workflow definition as JSON for save"), + .describe("DAG or confined script workflow definition as JSON"), }, async execute(args, context) { - const state = await store.read(); - if (args.action === "list") + const state = await readState(args.action === "run"); + if (args.action === "list") { return formatWorkflows(state.workflows, state.runs); - if (args.action === "history") - return JSON.stringify(state.runs.slice(-20), null, 2); + } + if (args.action === "history") { + return JSON.stringify( + state.runs.filter(isWorkflowRun).slice(0, 20), + null, + 2, + ); + } + if (args.action === "inspect") { + const name = requireText(args.name, "name"); + const definition = state.workflows.find((item) => item.name === name); + if (!definition) throw new Error(`Workflow ${name} does not exist.`); + return JSON.stringify(definition, null, 2); + } if (args.action === "save") { - if (!args.definition) - throw new Error("definition JSON is required for save."); - const definition = parseWorkflow(args.definition); - validateWorkflow(definition); + const definition = parseWorkflowDefinition( + requireText(args.definition, "definition"), + ); + if (definition.kind === "script") { + if (!options.workflows.scripts) { + throw new Error( + "Script workflows are disabled. Set workflows.scripts=true to enable them.", + ); + } + const validated = validateWorkflowScript(definition.source); + definition.sourceHash = validated.sourceHash; + } else { + validateWorkflow(definition); + } await store.saveWorkflow(definition); - return `Saved workflow ${definition.name} with ${definition.steps.length} steps.`; + return `Saved ${definition.kind ?? "dag"} workflow ${definition.name}.`; } - if (!args.name) throw new Error("name is required for run."); - const definition = state.workflows.find( - (workflow) => workflow.name === args.name, + if (args.action === "pause" || args.action === "resume" || + args.action === "stop" || args.action === "restart-agent") { + const runID = requireText(args.runID, "runID"); + await context.ask({ + permission: `multimodel.workflow.${args.action}`, + patterns: [runID], + always: [runID], + metadata: { runID }, + }); + if (args.action === "pause") await runs.pause(runID); + if (args.action === "resume") await runs.resume(runID); + if (args.action === "stop") await runs.stop(runID); + if (args.action === "restart-agent") { + await runs.restartAgent( + runID, + requireText(args.stepID, "stepID"), + ); + } + return runOutput((await store.getRun(runID))!); + } + const name = requireText(args.name, "name"); + const definition = state.workflows.find((workflow) => + workflow.name === name ); - if (!definition) - throw new Error(`Workflow ${args.name} does not exist.`); + if (!definition) throw new Error(`Workflow ${name} does not exist.`); + if (definition.kind === "script" && !options.workflows.scripts) { + throw new Error("Script workflows are disabled by configuration."); + } + const pattern = definition.kind === "script" + ? `${definition.name}:${workflowSourceHash(definition.source)}` + : definition.name; await context.ask({ permission: "multimodel.workflow", - patterns: [definition.name], - always: ["*"], + patterns: [pattern], + always: [pattern], metadata: { workflow: definition.name, - steps: definition.steps.length, + kind: definition.kind ?? "dag", + sourceHash: definition.kind === "script" + ? workflowSourceHash(definition.source) + : undefined, + background: args.background === true, }, }); - const run = await runWorkflow( - runner, - state.fleet, - context.sessionID, + return runOutput(await runs.startWorkflow({ + sessionID: context.sessionID, + messageID: context.messageID, definition, - args.input ?? "", - { - signal: context.abort, - onUpdate: (next) => store.saveRun(next).then(() => undefined), - }, - ); - return { - title: `${definition.name}: ${run.status}`, - output: run.final ?? run.error ?? `Workflow ${run.status}.`, - metadata: { runID: run.id, status: run.status, steps: run.steps }, - }; + input: args.input ?? "", + background: args.background, + signal: args.background ? undefined : context.abort, + })); }, }), }, async dispose() { + await runs.dispose(); await runner.close(); + await store.close(); }, }; }; @@ -231,38 +393,57 @@ function formatFleet(fleet: Awaited>["fleet"]) { if (fleet.members.length === 0) return "Fleet is empty."; return [ `Lead: ${fleet.leadID}`, - ...fleet.members.map( - (member) => - `${member.id === fleet.leadID ? "*" : "-"} ${member.id} · ${member.role} · ${member.model.providerID}/${member.model.modelID} · agent=${member.agent ?? "default"}${member.enabled ? "" : " · disabled"}`, + ...fleet.members.map((member) => + `${member.id === fleet.leadID ? "*" : "-"} ${member.id} · ${member.role} · ${member.model.providerID}/${member.model.modelID} · agent=${member.agent ?? "default"} · isolation=${member.isolation ?? "shared"}${member.enabled ? "" : " · disabled"}` ), ].join("\n"); } function formatWorkflows( - workflows: Awaited>["workflows"], - runs: Awaited>["runs"], + workflows: WorkflowDefinition[], + runs: DurableRun[], ) { if (workflows.length === 0) return "No workflows saved."; return [ - ...workflows.map( - (workflow) => - `${workflow.name} · ${workflow.steps.length} steps${workflow.description ? ` · ${workflow.description}` : ""}`, + ...workflows.map((workflow) => + workflow.kind === "script" + ? `${workflow.name} · script · sha256:${workflow.sourceHash ?? workflowSourceHash(workflow.source)}` + : `${workflow.name} · dag · ${workflow.steps.length} steps${workflow.description ? ` · ${workflow.description}` : ""}` ), "", `Recent runs: ${ runs - .slice(-10) + .filter(isWorkflowRun) + .slice(0, 10) .map((run) => `${run.definition}/${run.status}`) .join(", ") || "none" }`, ].join("\n"); } -function parseWorkflow(value: string): WorkflowDefinition { - const parsed: unknown = JSON.parse(value); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) - throw new Error("Workflow definition must be an object."); - return parsed as WorkflowDefinition; +function runOutput(run: DurableRun) { + return { + title: `${run.definition}: ${run.status}`, + output: run.background && run.status === "pending" + ? `Background run ${run.id} was admitted.` + : run.final ?? run.error ?? `Run ${run.id} is ${run.status}.`, + metadata: { + runID: run.id, + kind: run.kind, + status: run.status, + background: run.background === true, + steps: run.steps, + }, + }; +} + +function requireText(value: string | undefined, name: string) { + if (value?.trim()) return value; + throw new Error(`${name} is required.`); +} + +function absolute(directory: string, path: string) { + return isAbsolute(path) ? path : resolve(directory, path); } export default { id: "opencode-multimodel", server } satisfies PluginModule; diff --git a/packages/opencode-multimodel/src/state.ts b/packages/opencode-multimodel/src/state.ts index d7243db..c60a253 100644 --- a/packages/opencode-multimodel/src/state.ts +++ b/packages/opencode-multimodel/src/state.ts @@ -1,142 +1,1153 @@ -import { dirname, resolve } from "node:path"; +import { chmodSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, isAbsolute, resolve } from "node:path"; +import { Database } from "bun:sqlite"; import type { + CollaborationRun, + ComposerMode, + DagWorkflowDefinition, + DurableRun, Fleet, FleetMember, + LedgerEvent, PersistedState, + RunStatus, WorkflowDefinition, WorkflowRun, + WorkflowStepRun, + WorkspaceRecord, } from "./types.ts"; -const EMPTY_STATE: PersistedState = { - version: 1, - fleet: { leadID: "lead", members: [] }, - workflows: [], - runs: [], +const TERMINAL = ["completed", "failed", "cancelled", "stopped"] as const; +const EMPTY_FLEET: Fleet = { leadID: "lead", members: [] }; + +type StateStoreOptions = { + legacyPath?: string; + retention?: { runs: number; events: number }; +}; + +type RunRow = { + id: string; + kind: "workflow" | "collaboration"; + definition: string; + workflow_kind: "dag" | "script" | null; + session_id: string; + message_id: string | null; + input: string; + status: RunStatus; + mode: string | null; + participants: string; + final: string | null; + error: string | null; + background: number; + source_hash: string | null; + created_at: number; + updated_at: number; +}; + +type StepRow = { + step_id: string; + status: WorkflowStepRun["status"]; + member_id: string; + output: string | null; + error: string | null; + started_at: number | null; + completed_at: number | null; }; export class StateStore { - private lane = Promise.resolve(); + readonly path: string; + private readonly database: Database; + private readonly owner = crypto.randomUUID(); + private readonly retention: { runs: number; events: number }; + private readonly ready: Promise; - constructor(readonly path: string) {} + constructor(path: string, options: StateStoreOptions = {}) { + this.path = resolve(path); + this.retention = options.retention ?? { runs: 100, events: 10_000 }; + mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 }); + this.database = new Database(this.path, { create: true, strict: true }); + chmodSync(this.path, 0o600); + this.database.exec("PRAGMA journal_mode = WAL"); + this.database.exec("PRAGMA busy_timeout = 5000"); + this.database.exec("PRAGMA foreign_keys = ON"); + this.database.exec("PRAGMA synchronous = NORMAL"); + this.createSchema(); + this.secureDatabaseFiles(); + this.ready = this.initialize(options.legacyPath); + } async read(): Promise { - const file = Bun.file(this.path); - if (!(await file.exists())) return structuredClone(EMPTY_STATE); - const value: unknown = await file.json(); - return normalizeState(value); - } - - update(change: (state: PersistedState) => void | Promise) { - const next = this.lane.then(async () => { - const state = await this.read(); - await change(state); - await Bun.write( - this.path, - `${JSON.stringify({ ...state, runs: state.runs.slice(-100) }, null, 2)}\n`, - { createPath: true }, - ); - return state; - }); - this.lane = next.then( - () => undefined, - () => undefined, - ); - return next; + await this.ready; + return { + version: 2, + fleet: this.readFleet(), + workflows: this.listWorkflowsSync(), + runs: this.listRunsSync(), + events: this.listEventsSync(), + workspaces: this.listWorkspacesSync(), + }; } - initializeFleet(fleet: Fleet) { - return this.update((state) => { - if (state.fleet.members.length > 0) return; - state.fleet = normalizeFleet(fleet); + async initializeFleet(fleet: Fleet) { + await this.ready; + this.transaction(() => { + const count = this.database + .query<{ count: number }, []>("SELECT COUNT(*) AS count FROM fleet_members") + .get()?.count ?? 0; + if (count > 0) return; + this.writeFleet(normalizeFleet(fleet)); + this.appendEventSync(undefined, "fleet.initialized", { + members: fleet.members.map((member) => member.id), + }); }); } - setLead(leadID: string) { - return this.update((state) => { - if (!state.fleet.members.some((member) => member.id === leadID)) { + async setLead(leadID: string) { + await this.ready; + this.transaction(() => { + if (!this.memberExists(leadID)) { throw new Error(`Fleet member ${leadID} does not exist.`); } - state.fleet.leadID = leadID; + this.setMeta("fleet.lead", leadID); + this.appendEventSync(undefined, "fleet.lead", { memberID: leadID }); }); } - upsertMember(member: FleetMember) { - return this.update((state) => { - const index = state.fleet.members.findIndex( - (item) => item.id === member.id, + async upsertMember(member: FleetMember) { + await this.ready; + this.transaction(() => { + this.database.query( + `INSERT INTO fleet_members ( + id, role, provider_id, model_id, agent, system, enabled, isolation, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + role = excluded.role, + provider_id = excluded.provider_id, + model_id = excluded.model_id, + agent = excluded.agent, + system = excluded.system, + enabled = excluded.enabled, + isolation = excluded.isolation, + updated_at = excluded.updated_at`, + ).run( + member.id, + member.role, + member.model.providerID, + member.model.modelID, + member.agent ?? null, + member.system ?? null, + member.enabled ? 1 : 0, + member.isolation ?? "shared", + Date.now(), ); - if (index === -1) state.fleet.members.push(member); - if (index !== -1) state.fleet.members[index] = member; - if (!state.fleet.members.some((item) => item.id === state.fleet.leadID)) { - state.fleet.leadID = member.id; + if (!this.memberExists(this.meta("fleet.lead") ?? "")) { + this.setMeta("fleet.lead", member.id); } + this.appendEventSync(undefined, "fleet.member.saved", { + memberID: member.id, + enabled: member.enabled, + isolation: member.isolation ?? "shared", + }); }); } - removeMember(memberID: string) { - return this.update((state) => { - state.fleet.members = state.fleet.members.filter( - (member) => member.id !== memberID, + async enableMember(memberID: string, enabled: boolean) { + await this.ready; + const changed = this.database + .query("UPDATE fleet_members SET enabled = ?, updated_at = ? WHERE id = ?") + .run(enabled ? 1 : 0, Date.now(), memberID).changes; + if (changed === 0) throw new Error(`Fleet member ${memberID} does not exist.`); + await this.appendEvent(undefined, "fleet.member.enabled", { + memberID, + enabled, + }); + } + + async removeMember(memberID: string) { + await this.ready; + this.transaction(() => { + this.database.query("DELETE FROM fleet_members WHERE id = ?").run(memberID); + if (this.meta("fleet.lead") === memberID) { + const next = this.database + .query<{ id: string }, []>( + "SELECT id FROM fleet_members ORDER BY updated_at, id LIMIT 1", + ) + .get()?.id ?? "lead"; + this.setMeta("fleet.lead", next); + } + this.appendEventSync(undefined, "fleet.member.removed", { memberID }); + }); + } + + async saveWorkflow(definition: WorkflowDefinition) { + await this.ready; + const source = definition.kind === "script" ? definition.source : null; + const sourceHash = definition.kind === "script" + ? definition.sourceHash ?? workflowSourceHash(definition.source) + : null; + this.transaction(() => { + this.database.query( + `INSERT INTO workflows ( + name, kind, description, definition_json, source, source_hash, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + kind = excluded.kind, + description = excluded.description, + definition_json = excluded.definition_json, + source = excluded.source, + source_hash = excluded.source_hash, + updated_at = excluded.updated_at`, + ).run( + definition.name, + definition.kind ?? "dag", + definition.description ?? null, + JSON.stringify(definition.kind === "script" + ? { ...definition, sourceHash } + : { ...definition, kind: "dag" }), + source, + sourceHash, + Date.now(), + Date.now(), ); - if (state.fleet.leadID === memberID) - state.fleet.leadID = state.fleet.members[0]?.id ?? "lead"; + this.appendEventSync(undefined, "workflow.saved", { + name: definition.name, + kind: definition.kind ?? "dag", + sourceHash, + }); + }); + } + + async removeWorkflow(name: string) { + await this.ready; + this.database.query("DELETE FROM workflows WHERE name = ?").run(name); + await this.appendEvent(undefined, "workflow.removed", { name }); + } + + async createRun(run: DurableRun, tool: string) { + await this.ready; + const idempotencyKey = run.messageID + ? `${run.sessionID}\0${run.messageID}\0${tool}` + : undefined; + return this.transaction(() => { + if (idempotencyKey) { + const existing = this.database + .query<{ id: string }, [string]>( + "SELECT id FROM runs WHERE idempotency_key = ?", + ) + .get(idempotencyKey); + if (existing) return this.getRunSync(existing.id)!; + } + this.saveRunSync(run, idempotencyKey); + this.appendEventSync(run.id, "run.created", { + kind: run.kind, + definition: run.definition, + background: run.background === true, + }); + this.acquireLeaseSync(run.id); + this.pruneSync(); + return structuredClone(run); + }); + } + + async saveRun(run: DurableRun) { + await this.ready; + this.transaction(() => { + if (run.status === "running" || run.status === "paused") { + this.acquireLeaseSync(run.id); + } else { + this.assertLeaseOwnerOrFreeSync(run.id); + } + this.saveRunSync(run); + if (TERMINAL.includes(run.status as (typeof TERMINAL)[number]) || + run.status === "interrupted") this.releaseLeaseSync(run.id); + this.appendEventSync(run.id, "run.updated", { + status: run.status, + steps: run.steps.map((step) => ({ id: step.id, status: step.status })), + }); + this.pruneSync(); + }); + return run; + } + + async getRun(runID: string) { + await this.ready; + return this.getRunSync(runID); + } + + async listRuns(limit = 100) { + await this.ready; + return this.listRunsSync(limit); + } + + async setRunControl( + runID: string, + control: "run" | "pause" | "stop", + ) { + await this.ready; + const changed = this.database + .query("UPDATE runs SET control = ?, updated_at = ? WHERE id = ?") + .run(control, Date.now(), runID).changes; + if (changed === 0) throw new Error(`Run ${runID} does not exist.`); + await this.appendEvent(runID, `run.${control}.requested`, {}); + } + + async renewLease(runID: string) { + await this.ready; + if (!this.tryAcquireLeaseSync(runID)) { + throw new Error(`Run ${runID} is leased by another plugin process.`); + } + } + + async claimLease(runID: string) { + await this.ready; + return this.transaction(() => this.tryAcquireLeaseSync(runID)); + } + + async getRunControl(runID: string) { + await this.ready; + return this.database + .query<{ control: "run" | "pause" | "stop" }, [string]>( + "SELECT control FROM runs WHERE id = ?", + ) + .get(runID)?.control ?? "stop"; + } + + async appendEvent(runID: string | undefined, type: string, data: unknown) { + await this.ready; + this.transaction(() => { + this.appendEventSync(runID, type, data); + this.pruneEventsSync(); }); } - saveWorkflow(definition: WorkflowDefinition) { - return this.update((state) => { - const index = state.workflows.findIndex( - (item) => item.name === definition.name, + async saveChildSession( + key: string, + input: { + parentSessionID: string; + memberID: string; + providerID: string; + modelID: string; + agent?: string; + sessionID: string; + workspaceID?: string; + }, + ) { + await this.ready; + this.database.query( + `INSERT INTO child_sessions ( + key, parent_session_id, member_id, provider_id, model_id, agent, + session_id, workspace_id, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + session_id = excluded.session_id, + workspace_id = excluded.workspace_id, + updated_at = excluded.updated_at`, + ).run( + key, + input.parentSessionID, + input.memberID, + input.providerID, + input.modelID, + input.agent ?? null, + input.sessionID, + input.workspaceID ?? null, + Date.now(), + ); + } + + async getChildSession(key: string) { + await this.ready; + return this.database + .query<{ session_id: string; workspace_id: string | null }, [string]>( + "SELECT session_id, workspace_id FROM child_sessions WHERE key = ?", + ) + .get(key); + } + + async deleteChildSessionsForWorkspace(workspaceID: string) { + await this.ready; + this.database + .query("DELETE FROM child_sessions WHERE workspace_id = ?") + .run(workspaceID); + } + + async saveAgentCall(input: { + runID: string; + stepID?: string; + callIndex: number; + memberID: string; + prompt: string; + options: unknown; + status: "running" | "completed" | "failed" | "cancelled"; + sessionID?: string; + output?: string; + error?: string; + }) { + await this.ready; + this.transaction(() => { + this.database.query( + `INSERT INTO agent_calls ( + run_id, step_id, call_index, member_id, prompt, options_json, status, + child_session_id, output, error, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(run_id, call_index) DO UPDATE SET + step_id = excluded.step_id, + member_id = excluded.member_id, + prompt = excluded.prompt, + options_json = excluded.options_json, + status = excluded.status, + child_session_id = excluded.child_session_id, + output = excluded.output, + error = excluded.error, + updated_at = excluded.updated_at`, + ).run( + input.runID, + input.stepID ?? null, + input.callIndex, + input.memberID, + input.prompt, + JSON.stringify(input.options), + input.status, + input.sessionID ?? null, + input.output ?? null, + input.error ?? null, + Date.now(), + Date.now(), ); - if (index === -1) state.workflows.push(definition); - if (index !== -1) state.workflows[index] = definition; + this.appendEventSync(input.runID, `agent.${input.status}`, { + stepID: input.stepID, + callIndex: input.callIndex, + memberID: input.memberID, + childSessionID: input.sessionID, + error: input.error, + }); }); } - saveRun(run: WorkflowRun) { - return this.update((state) => { - const index = state.runs.findIndex((item) => item.id === run.id); - if (index === -1) state.runs.push(run); - if (index !== -1) state.runs[index] = run; + async cachedAgentCalls(runID: string) { + await this.ready; + return this.database + .query<{ + call_index: number; + member_id: string; + prompt: string; + options_json: string; + child_session_id: string | null; + output: string | null; + status: string; + }, [string]>( + `SELECT call_index, member_id, prompt, options_json, child_session_id, + output, status FROM agent_calls WHERE run_id = ? ORDER BY call_index`, + ) + .all(runID); + } + + async deleteAgentCallsFrom(runID: string, callIndex: number) { + await this.ready; + this.database + .query("DELETE FROM agent_calls WHERE run_id = ? AND call_index >= ?") + .run(runID, callIndex); + await this.appendEvent(runID, "agent.cache.truncated", { callIndex }); + } + + async saveWorkspace(workspace: WorkspaceRecord) { + await this.ready; + this.database.query( + `INSERT INTO workspaces ( + id, run_id, member_id, directory, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + directory = excluded.directory, + status = excluded.status, + updated_at = excluded.updated_at`, + ).run( + workspace.id, + workspace.runID ?? null, + workspace.memberID, + workspace.directory ?? null, + workspace.status, + workspace.createdAt, + workspace.updatedAt, + ); + await this.appendEvent(workspace.runID, "workspace.saved", { + workspaceID: workspace.id, + memberID: workspace.memberID, + status: workspace.status, }); } + + async listWorkspaces(runID?: string) { + await this.ready; + return this.listWorkspacesSync(runID); + } + + async markWorkspaceRemoved(id: string) { + await this.ready; + this.database + .query("UPDATE workspaces SET status = 'removed', updated_at = ? WHERE id = ?") + .run(Date.now(), id); + } + + async getSessionMode(sessionID: string) { + await this.ready; + return this.database + .query<{ + mode: ComposerMode; + collaboration_mode: string | null; + workflow_name: string | null; + }, [string]>( + `SELECT mode, collaboration_mode, workflow_name + FROM session_modes WHERE session_id = ?`, + ) + .get(sessionID); + } + + async setSessionMode( + sessionID: string, + mode: ComposerMode, + selection?: string, + ) { + await this.ready; + this.database.query( + `INSERT INTO session_modes ( + session_id, mode, collaboration_mode, workflow_name, updated_at + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + mode = excluded.mode, + collaboration_mode = excluded.collaboration_mode, + workflow_name = excluded.workflow_name, + updated_at = excluded.updated_at`, + ).run( + sessionID, + mode, + mode === "team" ? selection ?? null : null, + mode === "workflow" ? selection ?? null : null, + Date.now(), + ); + } + + async close() { + await this.ready; + this.database.close(false); + } + + private createSchema() { + this.database.exec(` + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS fleet_members ( + id TEXT PRIMARY KEY, + role TEXT NOT NULL, + provider_id TEXT NOT NULL, + model_id TEXT NOT NULL, + agent TEXT, + system TEXT, + enabled INTEGER NOT NULL, + isolation TEXT NOT NULL DEFAULT 'shared', + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS session_modes ( + session_id TEXT PRIMARY KEY, + mode TEXT NOT NULL, + collaboration_mode TEXT, + workflow_name TEXT, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS workflows ( + name TEXT PRIMARY KEY, + kind TEXT NOT NULL, + description TEXT, + definition_json TEXT NOT NULL, + source TEXT, + source_hash TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS runs ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + definition TEXT NOT NULL, + workflow_kind TEXT, + session_id TEXT NOT NULL, + message_id TEXT, + idempotency_key TEXT UNIQUE, + input TEXT NOT NULL, + status TEXT NOT NULL, + mode TEXT, + participants TEXT NOT NULL DEFAULT '[]', + final TEXT, + error TEXT, + background INTEGER NOT NULL DEFAULT 0, + source_hash TEXT, + control TEXT NOT NULL DEFAULT 'run', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS run_steps ( + run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE, + step_index INTEGER NOT NULL, + step_id TEXT NOT NULL, + status TEXT NOT NULL, + member_id TEXT NOT NULL, + output TEXT, + error TEXT, + started_at INTEGER, + completed_at INTEGER, + PRIMARY KEY (run_id, step_id) + ); + CREATE TABLE IF NOT EXISTS agent_calls ( + run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE, + step_id TEXT, + call_index INTEGER NOT NULL, + member_id TEXT NOT NULL, + prompt TEXT NOT NULL, + options_json TEXT NOT NULL, + status TEXT NOT NULL, + child_session_id TEXT, + output TEXT, + error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (run_id, call_index) + ); + CREATE TABLE IF NOT EXISTS child_sessions ( + key TEXT PRIMARY KEY, + parent_session_id TEXT NOT NULL, + member_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + model_id TEXT NOT NULL, + agent TEXT, + session_id TEXT NOT NULL, + workspace_id TEXT, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, + run_id TEXT, + member_id TEXT NOT NULL, + directory TEXT, + status TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT, + type TEXT NOT NULL, + data_json TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS leases ( + run_id TEXT PRIMARY KEY REFERENCES runs(id) ON DELETE CASCADE, + owner TEXT NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS runs_status_updated ON runs(status, updated_at); + CREATE INDEX IF NOT EXISTS events_run_id ON events(run_id, id); + CREATE INDEX IF NOT EXISTS workspaces_run_id ON workspaces(run_id); + `); + } + + private secureDatabaseFiles() { + [this.path, `${this.path}-wal`, `${this.path}-shm`] + .filter(existsSync) + .forEach((path) => chmodSync(path, 0o600)); + } + + private async initialize(legacyPath?: string) { + await this.importLegacy(legacyPath); + this.transaction(() => { + const now = Date.now(); + const interrupted = this.database + .query<{ id: string }, [number]>( + `SELECT runs.id FROM runs + LEFT JOIN leases ON leases.run_id = runs.id + WHERE runs.status IN ('pending', 'running', 'paused') + AND (leases.expires_at IS NULL OR leases.expires_at < ?)`, + ) + .all(now); + interrupted.forEach((run) => { + this.database + .query("UPDATE runs SET status = 'interrupted', updated_at = ? WHERE id = ?") + .run(now, run.id); + this.database + .query("UPDATE run_steps SET status = 'interrupted' WHERE run_id = ? AND status = 'running'") + .run(run.id); + this.appendEventSync(run.id, "run.interrupted", { + reason: "process lease expired", + }); + }); + this.database.query("DELETE FROM leases WHERE expires_at < ?").run(now); + this.pruneSync(); + }); + } + + private async importLegacy(legacyPath?: string) { + if (this.meta("legacy.imported")) return; + const path = legacyPath ?? this.path.replace(/\.sqlite$/, ".json"); + const file = Bun.file(path); + const exists = await file.exists(); + const value: unknown = exists ? await file.json() : undefined; + this.transaction(() => { + if (this.meta("legacy.imported")) return; + if (value !== undefined) { + const state = normalizeLegacyState(value); + this.writeFleet(state.fleet); + state.workflows.forEach((workflow) => this.saveWorkflowSync(workflow)); + state.runs.forEach((run) => this.saveRunSync(run)); + this.appendEventSync(undefined, "legacy.imported", { + path, + workflows: state.workflows.length, + runs: state.runs.length, + }); + } + this.setMeta("legacy.imported", exists ? path : "none"); + }); + } + + private transaction(change: () => Value) { + return this.database.transaction(change)(); + } + + private readFleet(): Fleet { + const members = this.database + .query<{ + id: string; + role: string; + provider_id: string; + model_id: string; + agent: string | null; + system: string | null; + enabled: number; + isolation: "shared" | "worktree"; + }, []>( + `SELECT id, role, provider_id, model_id, agent, system, enabled, isolation + FROM fleet_members ORDER BY updated_at, id`, + ) + .all() + .map((member) => ({ + id: member.id, + role: member.role, + model: { providerID: member.provider_id, modelID: member.model_id }, + agent: member.agent ?? undefined, + system: member.system ?? undefined, + enabled: member.enabled === 1, + isolation: member.isolation, + })); + const selected = this.meta("fleet.lead") ?? members[0]?.id ?? "lead"; + return { + leadID: members.some((member) => member.id === selected) + ? selected + : members[0]?.id ?? "lead", + members, + }; + } + + private writeFleet(fleet: Fleet) { + this.database.exec("DELETE FROM fleet_members"); + fleet.members.forEach((member) => { + this.database.query( + `INSERT INTO fleet_members ( + id, role, provider_id, model_id, agent, system, enabled, isolation, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + member.id, + member.role, + member.model.providerID, + member.model.modelID, + member.agent ?? null, + member.system ?? null, + member.enabled ? 1 : 0, + member.isolation ?? "shared", + Date.now(), + ); + }); + this.setMeta("fleet.lead", fleet.leadID); + } + + private saveWorkflowSync(definition: WorkflowDefinition) { + const sourceHash = definition.kind === "script" + ? definition.sourceHash ?? workflowSourceHash(definition.source) + : null; + this.database.query( + `INSERT INTO workflows ( + name, kind, description, definition_json, source, source_hash, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + kind = excluded.kind, + description = excluded.description, + definition_json = excluded.definition_json, + source = excluded.source, + source_hash = excluded.source_hash, + updated_at = excluded.updated_at`, + ).run( + definition.name, + definition.kind ?? "dag", + definition.description ?? null, + JSON.stringify(definition.kind === "script" + ? { ...definition, sourceHash } + : { ...definition, kind: "dag" }), + definition.kind === "script" ? definition.source : null, + sourceHash, + Date.now(), + Date.now(), + ); + } + + private listWorkflowsSync() { + return this.database + .query<{ definition_json: string }, []>( + "SELECT definition_json FROM workflows ORDER BY name", + ) + .all() + .map((row) => JSON.parse(row.definition_json) as WorkflowDefinition); + } + + private saveRunSync(run: DurableRun, idempotencyKey?: string) { + this.database.query( + `INSERT INTO runs ( + id, kind, definition, workflow_kind, session_id, message_id, + idempotency_key, input, status, mode, participants, final, error, + background, source_hash, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + status = excluded.status, + participants = excluded.participants, + final = excluded.final, + error = excluded.error, + background = excluded.background, + source_hash = excluded.source_hash, + updated_at = excluded.updated_at`, + ).run( + run.id, + run.kind, + run.definition, + run.kind === "workflow" ? run.workflowKind : null, + run.sessionID, + run.messageID ?? null, + idempotencyKey ?? null, + run.input, + run.status, + run.kind === "collaboration" ? run.mode : null, + JSON.stringify(run.kind === "collaboration" ? run.participants : []), + run.final ?? null, + run.error ?? null, + run.background ? 1 : 0, + run.kind === "workflow" ? run.sourceHash ?? null : null, + run.createdAt, + run.updatedAt, + ); + this.database.query("DELETE FROM run_steps WHERE run_id = ?").run(run.id); + run.steps.forEach((step, index) => { + this.database.query( + `INSERT INTO run_steps ( + run_id, step_index, step_id, status, member_id, output, error, + started_at, completed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + run.id, + index, + step.id, + step.status, + step.memberID, + step.output ?? null, + step.error ?? null, + step.startedAt ?? null, + step.completedAt ?? null, + ); + }); + } + + private getRunSync(runID: string) { + const row = this.database + .query( + `SELECT id, kind, definition, workflow_kind, session_id, message_id, + input, status, mode, participants, final, error, background, + source_hash, created_at, updated_at + FROM runs WHERE id = ?`, + ) + .get(runID); + return row ? this.hydrateRun(row) : undefined; + } + + private listRunsSync(limit = 100) { + return this.database + .query( + `SELECT id, kind, definition, workflow_kind, session_id, message_id, + input, status, mode, participants, final, error, background, + source_hash, created_at, updated_at + FROM runs ORDER BY created_at DESC LIMIT ?`, + ) + .all(limit) + .map((row) => this.hydrateRun(row)); + } + + private hydrateRun(row: RunRow): DurableRun { + const steps = this.database + .query( + `SELECT step_id, status, member_id, output, error, started_at, completed_at + FROM run_steps WHERE run_id = ? ORDER BY step_index`, + ) + .all(row.id) + .map((step) => ({ + id: step.step_id, + status: step.status, + memberID: step.member_id, + output: step.output ?? undefined, + error: step.error ?? undefined, + startedAt: step.started_at ?? undefined, + completedAt: step.completed_at ?? undefined, + })); + const common = { + id: row.id, + definition: row.definition, + sessionID: row.session_id, + messageID: row.message_id ?? undefined, + input: row.input, + status: row.status, + steps, + final: row.final ?? undefined, + error: row.error ?? undefined, + background: row.background === 1, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + if (row.kind === "workflow") { + return { + ...common, + kind: "workflow", + workflowKind: row.workflow_kind ?? "dag", + sourceHash: row.source_hash ?? undefined, + } satisfies WorkflowRun; + } + return { + ...common, + kind: "collaboration", + mode: row.mode as CollaborationRun["mode"], + participants: JSON.parse(row.participants) as string[], + } satisfies CollaborationRun; + } + + private listEventsSync() { + return this.database + .query<{ + id: number; + run_id: string | null; + type: string; + data_json: string; + created_at: number; + }, [number]>( + `SELECT id, run_id, type, data_json, created_at + FROM events ORDER BY id DESC LIMIT ?`, + ) + .all(this.retention.events) + .reverse() + .map((event) => ({ + id: event.id, + runID: event.run_id ?? undefined, + type: event.type, + data: JSON.parse(event.data_json) as unknown, + createdAt: event.created_at, + } satisfies LedgerEvent)); + } + + private appendEventSync(runID: string | undefined, type: string, data: unknown) { + this.database + .query("INSERT INTO events (run_id, type, data_json, created_at) VALUES (?, ?, ?, ?)") + .run(runID ?? null, type, JSON.stringify(data), Date.now()); + } + + private listWorkspacesSync(runID?: string) { + const rows = runID + ? this.database.query<{ + id: string; + run_id: string | null; + member_id: string; + directory: string | null; + status: WorkspaceRecord["status"]; + created_at: number; + updated_at: number; + }, [string]>( + `SELECT id, run_id, member_id, directory, status, created_at, updated_at + FROM workspaces WHERE run_id = ? ORDER BY created_at`, + ).all(runID) + : this.database.query<{ + id: string; + run_id: string | null; + member_id: string; + directory: string | null; + status: WorkspaceRecord["status"]; + created_at: number; + updated_at: number; + }, []>( + `SELECT id, run_id, member_id, directory, status, created_at, updated_at + FROM workspaces ORDER BY created_at`, + ).all(); + return rows.map((workspace) => ({ + id: workspace.id, + runID: workspace.run_id ?? undefined, + memberID: workspace.member_id, + directory: workspace.directory ?? undefined, + status: workspace.status, + createdAt: workspace.created_at, + updatedAt: workspace.updated_at, + })); + } + + private acquireLeaseSync(runID: string) { + if (this.tryAcquireLeaseSync(runID)) return; + throw new Error(`Run ${runID} is leased by another plugin process.`); + } + + private tryAcquireLeaseSync(runID: string) { + const now = Date.now(); + return this.database.query( + `INSERT INTO leases (run_id, owner, expires_at) VALUES (?, ?, ?) + ON CONFLICT(run_id) DO UPDATE SET + owner = excluded.owner, + expires_at = excluded.expires_at + WHERE leases.owner = excluded.owner OR leases.expires_at < ?`, + ).run(runID, this.owner, now + 60_000, now).changes > 0; + } + + private releaseLeaseSync(runID: string) { + this.database + .query("DELETE FROM leases WHERE run_id = ? AND owner = ?") + .run(runID, this.owner); + } + + private assertLeaseOwnerOrFreeSync(runID: string) { + const owner = this.database + .query<{ owner: string }, [string]>( + "SELECT owner FROM leases WHERE run_id = ?", + ) + .get(runID)?.owner; + if (!owner || owner === this.owner) return; + throw new Error(`Run ${runID} is leased by another plugin process.`); + } + + private pruneSync() { + this.database.query( + `DELETE FROM runs WHERE id IN ( + SELECT id FROM runs + WHERE status IN ('completed', 'failed', 'cancelled', 'stopped') + ORDER BY updated_at DESC LIMIT -1 OFFSET ? + )`, + ).run(this.retention.runs); + this.pruneEventsSync(); + } + + private pruneEventsSync() { + this.database.query( + `DELETE FROM events WHERE id IN ( + SELECT id FROM events ORDER BY id DESC LIMIT -1 OFFSET ? + )`, + ).run(this.retention.events); + } + + private memberExists(memberID: string) { + return !!this.database + .query<{ id: string }, [string]>("SELECT id FROM fleet_members WHERE id = ?") + .get(memberID); + } + + private meta(key: string) { + return this.database + .query<{ value: string }, [string]>("SELECT value FROM meta WHERE key = ?") + .get(key)?.value; + } + + private setMeta(key: string, value: string) { + this.database + .query( + `INSERT INTO meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + ) + .run(key, value); + } } +export function defaultDatabasePath(directory: string) { + return resolve(directory, ".opencode", "multimodel.sqlite"); +} + +/** @deprecated Use defaultDatabasePath. */ export function defaultStatePath(directory: string) { return resolve(directory, ".opencode", "multimodel.json"); } +export function resolveDatabasePath(directory: string, path: string) { + return isAbsolute(path) ? path : resolve(directory, path); +} + export function stateDirectory(path: string) { return dirname(path); } -function normalizeState(value: unknown): PersistedState { - if (!value || typeof value !== "object" || Array.isArray(value)) - return structuredClone(EMPTY_STATE); - const input = value as Partial; - const fleet = normalizeFleet(input.fleet); - return { - version: 1, - fleet, - workflows: Array.isArray(input.workflows) - ? input.workflows.filter(isWorkflow) - : [], - runs: Array.isArray(input.runs) ? input.runs.filter(isRun).slice(-100) : [], +export function workflowSourceHash(source: string) { + return new Bun.CryptoHasher("sha256") + .update(normalizeWorkflowSource(source)) + .digest("hex"); +} + +export function normalizeWorkflowSource(source: string) { + return source.replace(/\r\n?/g, "\n").trim(); +} + +function normalizeLegacyState(value: unknown): { + fleet: Fleet; + workflows: WorkflowDefinition[]; + runs: WorkflowRun[]; +} { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Legacy multimodel state must be a JSON object."); + } + const input = value as { + fleet?: unknown; + workflows?: unknown; + runs?: unknown; }; + const fleet = normalizeFleet(input.fleet); + const workflows = Array.isArray(input.workflows) + ? input.workflows.filter(isLegacyWorkflow).map((workflow) => ({ + ...workflow, + kind: "dag" as const, + })) + : []; + const runs = Array.isArray(input.runs) + ? input.runs.filter(isLegacyRun).map((run) => ({ + ...run, + kind: "workflow" as const, + workflowKind: "dag" as const, + status: run.status === "running" || run.status === "pending" + ? "interrupted" as const + : run.status, + steps: run.steps.map((step) => ({ + ...step, + status: step.status === "running" ? "interrupted" as const : step.status, + })), + })) + : []; + return { fleet, workflows, runs }; } function normalizeFleet(value: unknown): Fleet { - if (!value || typeof value !== "object" || Array.isArray(value)) - return structuredClone(EMPTY_STATE.fleet); + if (!value || typeof value !== "object" || Array.isArray(value)) { + return structuredClone(EMPTY_FLEET); + } const input = value as Partial; const members = Array.isArray(input.members) - ? input.members.filter(isMember) + ? input.members.filter(isMember).map((member) => ({ + ...member, + isolation: member.isolation ?? "shared" as const, + })) : []; - const leadID = - typeof input.leadID === "string" && - members.some((member) => member.id === input.leadID) - ? input.leadID - : (members[0]?.id ?? "lead"); + const leadID = typeof input.leadID === "string" && + members.some((member) => member.id === input.leadID) + ? input.leadID + : members[0]?.id ?? "lead"; return { leadID, members }; } @@ -153,15 +1164,15 @@ function isMember(value: unknown): value is FleetMember { ); } -function isWorkflow(value: unknown): value is WorkflowDefinition { +function isLegacyWorkflow(value: unknown): value is DagWorkflowDefinition { if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const item = value as Partial; + const item = value as { name?: unknown; steps?: unknown }; return typeof item.name === "string" && Array.isArray(item.steps); } -function isRun(value: unknown): value is WorkflowRun { +function isLegacyRun(value: unknown): value is Omit { if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const item = value as Partial; + const item = value as { id?: unknown; definition?: unknown; steps?: unknown }; return ( typeof item.id === "string" && typeof item.definition === "string" && diff --git a/packages/opencode-multimodel/src/tui.tsx b/packages/opencode-multimodel/src/tui.tsx index d94f8ab..8452d33 100644 --- a/packages/opencode-multimodel/src/tui.tsx +++ b/packages/opencode-multimodel/src/tui.tsx @@ -2,10 +2,13 @@ import type { TuiPlugin, TuiPluginApi, TuiPluginModule, + TuiPromptInfo, + TuiPromptRef, TuiRouteCurrent, } from "@opencode-ai/plugin/tui"; import type { JSX } from "@opentui/solid"; import { + createEffect, createResource, createSignal, For, @@ -13,34 +16,61 @@ import { onMount, Show, } from "solid-js"; -import { collaborate } from "./collaborate.ts"; import { asOpenCodeClient, discoverFleet, OpenCodeAgentRunner, } from "./opencode.ts"; +import { RunService } from "./orchestration.ts"; import { parseOptions, type MultiModelOptions } from "./options.ts"; -import { defaultStatePath, StateStore } from "./state.ts"; +import { resolveDatabasePath, StateStore } from "./state.ts"; import { COLLAB_MODES, type CollabActivity, type CollabMode, + type ComposerMode, + type WorkflowDefinition, } from "./types.ts"; -import { runWorkflow } from "./workflow.ts"; +import { loadWorkflowDirectories } from "./workflow-files.ts"; const ROUTE_FLEET = "multimodel.fleet"; const ROUTE_COLLAB = "multimodel.collab"; const ROUTE_WORKFLOWS = "multimodel.workflows"; const ROUTE_WORKFLOW = "multimodel.workflow"; +const ROUTE_RUNS = "multimodel.runs"; +const ROUTE_RUN = "multimodel.run"; +const ROUTE_GRAPH = "multimodel.graph"; const tui: TuiPlugin = async (api, rawOptions) => { const options = parseOptions(rawOptions); const client = asOpenCodeClient(api.client); const store = new StateStore( - options.statePath ?? defaultStatePath(api.state.path.directory), + resolveDatabasePath(api.state.path.directory, options.databasePath), + { + legacyPath: options.statePath + ? resolveDatabasePath(api.state.path.directory, options.statePath) + : undefined, + retention: options.retention, + }, ); await store.initializeFleet(options.fleet ?? (await discoverFleet(client))); - const runner = new OpenCodeAgentRunner(client); + await loadWorkflowDirectories( + store, + api.state.path.directory, + options.workflows, + ); + const runner = new OpenCodeAgentRunner(client, store); + const runs = new RunService(store, runner, options); + const composer = createComposerController( + options, + (await store.read()).workflows, + ); + + if (options.composer.enabled && api.slots) { + registerComposerInputRouting(api, options, composer); + registerComposerSlots(api, store, options, composer); + } + watchBackgroundRuns(api, store, composer); api.route.register([ { @@ -55,7 +85,7 @@ const tui: TuiPlugin = async (api, rawOptions) => { @@ -73,11 +103,29 @@ const tui: TuiPlugin = async (api, rawOptions) => { ), }, + { + name: ROUTE_RUNS, + render: ({ params }) => ( + + ), + }, + { + name: ROUTE_RUN, + render: ({ params }) => ( + + ), + }, + { + name: ROUTE_GRAPH, + render: ({ params }) => ( + + ), + }, ]); api.keymap.registerLayer({ @@ -124,7 +172,7 @@ const tui: TuiPlugin = async (api, rawOptions) => { namespace: "palette", slashName: "workflow", run() { - void selectWorkflow(api, store); + void selectWorkflow(api, store, composer); }, }, { @@ -141,10 +189,49 @@ const tui: TuiPlugin = async (api, rawOptions) => { api.ui.dialog.clear(); }, }, + { + name: "multimodel.mode", + title: "Composer mode: SINGLE · TEAM · WORKFLOW", + description: "Choose how normal TUI prompts are submitted", + category: "Multi-model", + namespace: "palette", + slashName: "mode", + run() { + void chooseComposerMode(api, store, options, composer); + }, + }, + { + name: "multimodel.runs", + title: "Multi-model run ledger", + description: "Inspect foreground and background orchestration runs", + category: "Multi-model", + namespace: "palette", + slashName: "runs", + run() { + api.route.navigate(ROUTE_RUNS, { returnRoute: api.route.current }); + api.ui.dialog.clear(); + }, + }, + { + name: "multimodel.graph", + title: "Multi-model wire graph", + description: "View routes between fleet members in recent runs", + category: "Multi-model", + namespace: "palette", + slashName: "graph", + run() { + api.route.navigate(ROUTE_GRAPH, { returnRoute: api.route.current }); + api.ui.dialog.clear(); + }, + }, ], }); - api.lifecycle.onDispose(() => runner.close()); + api.lifecycle.onDispose(async () => { + await runs.dispose(); + await runner.close(); + await store.close(); + }); }; function FleetScreen(props: ScreenProps) { @@ -233,7 +320,7 @@ function WorkflowsScreen(props: ScreenProps) { {(workflow) => ( - {workflow.name} · {workflow.steps.length} steps ·{" "} + {workflow.name} · {workflowSummary(workflow)} ·{" "} {workflow.description ?? "No description"} )} @@ -246,7 +333,7 @@ function WorkflowsScreen(props: ScreenProps) { No runs yet. } > - + {(run) => ( { - const state = await props.store.read(); const mode = isMode(props.params?.mode) ? props.params.mode : props.options.defaultMode; const sessionID = stringParam(props.params?.sessionID, "sessionID"); const prompt = stringParam(props.params?.prompt, "prompt"); - return collaborate(props.runner, state.fleet, sessionID, prompt, { + return props.runs.startCollaboration({ + sessionID, + prompt, mode, - maxWorkers: props.options.maxWorkers, - maxParallel: props.options.maxParallel, signal: controller.signal, onActivity(event) { setActivity((current) => ({ ...current, [event.memberID]: event })); @@ -331,10 +417,10 @@ function CollabScreen( {(value) => ( - Done · lead={value().leadID} · {value().participants.join(", ")} + {value().status} · run={value().id} - {value().final.text} + {value().final ?? value().error ?? "No output."} )} @@ -344,7 +430,7 @@ function CollabScreen( ); } -function WorkflowScreen(props: ScreenProps & { runner: OpenCodeAgentRunner }) { +function WorkflowScreen(props: ScreenProps & { runs: RunService }) { const controller = new AbortController(); const [snapshot, setSnapshot] = createSignal("Starting…"); const [output] = createResource(async () => { @@ -354,22 +440,22 @@ function WorkflowScreen(props: ScreenProps & { runner: OpenCodeAgentRunner }) { (workflow) => workflow.name === name, ); if (!definition) throw new Error(`Workflow ${name} does not exist.`); - return runWorkflow( - props.runner, - state.fleet, - stringParam(props.params?.sessionID, "sessionID"), + if (definition.kind === "script") { + throw new Error( + "Run script workflows through the native /workflow command so OpenCode can request source-hash permission.", + ); + } + return props.runs.startWorkflow({ + sessionID: stringParam(props.params?.sessionID, "sessionID"), definition, - typeof props.params?.input === "string" ? props.params.input : "", - { - signal: controller.signal, - async onUpdate(run) { - setSnapshot( - `${run.status} · ${run.steps.filter((step) => step.status === "completed").length}/${run.steps.length} steps`, - ); - await props.store.saveRun(run); - }, - }, - ); + input: typeof props.params?.input === "string" ? props.params.input : "", + signal: controller.signal, + }).then((run) => { + setSnapshot( + `${run.status} · ${run.steps.filter((step) => step.status === "completed").length}/${run.steps.length} steps`, + ); + return run; + }); }); onCleanup(() => controller.abort()); useBackKey(props.api, props.params); @@ -413,6 +499,665 @@ function WorkflowScreen(props: ScreenProps & { runner: OpenCodeAgentRunner }) { ); } +function RunLedgerScreen(props: ScreenProps) { + const state = usePollingState(props.store); + useBackKey(props.api, props.params); + return ( + + Loading ledger…} + > + {(value) => ( + + 0} + fallback={No runs yet.} + > + + {(run) => ( + props.api.route.navigate(ROUTE_RUN, { + runID: run.id, + returnRoute: props.api.route.current, + })} + > + {run.status} + {run.definition} + {run.kind} + {run.id} + + )} + + + + Active dashboards refresh every 500 ms; idle dashboards every 2 s. + + + )} + + + ); +} + +function RunDetailScreen(props: ScreenProps) { + const state = usePollingState(props.store); + const runID = stringParam(props.params?.runID, "runID"); + useBackKey(props.api, props.params); + return ( + + run.id === runID)} + fallback={Loading run…} + > + {(run) => ( + + + {run().definition} · {run().status} · {run().kind} + + + {(step) => ( + + {step.id} · {step.memberID} · {step.status} + {step.error ? ` · ${step.error}` : ""} + + )} + + + + {run().final ?? run().error} + + + Ledger events + event.runID === runID).slice(-20)}> + {(event) => ( + + {event.id} · {event.type} + + )} + + + )} + + + ); +} + +function GraphScreen(props: ScreenProps) { + const state = usePollingState(props.store); + useBackKey(props.api, props.params); + return ( + + Loading graph…} + > + {(value) => ( + + + Fleet · lead={value().fleet.leadID} + + + {(member) => ( + + {member.id === value().fleet.leadID + ? `human → ${member.id} → human` + : `${member.id} → ${value().fleet.leadID}`} + {` · ${member.model.providerID}/${member.model.modelID}`} + + )} + + Recent routes + + {(run) => ( + + {run.id} · {run.steps.map((step) => step.memberID).join(" → ") || run.definition} + + )} + + + )} + + + ); +} + +type ComposerSelection = { + mode: ComposerMode; + collaborationMode: CollabMode; + workflowName?: string; +}; + +type ComposerController = { + selection: () => ComposerSelection; + setSelection: (value: ComposerSelection) => void; + ref: (current: TuiRouteCurrent) => TuiPromptRef | undefined; + addRef: (sessionID: string, value: TuiPromptRef) => void; + removeRef: (sessionID: string, value: TuiPromptRef) => void; + sessionID: () => string; + setSessionID: (value: string) => void; + workflows: () => WorkflowDefinition[]; + setWorkflows: (value: WorkflowDefinition[]) => void; +}; + +function createComposerController( + options: MultiModelOptions, + initialWorkflows: WorkflowDefinition[], +): ComposerController { + const [selection, setSelection] = createSignal({ + mode: options.composer.initial, + collaborationMode: options.defaultMode, + }); + const refs = new Map>(); + const [sessionID, setSessionID] = createSignal("__home__"); + const [workflows, setWorkflows] = createSignal(initialWorkflows); + return { + selection, + setSelection, + ref(current) { + const currentSessionID = current.name === "home" + ? "__home__" + : current.name === "session" && + typeof current.params?.sessionID === "string" + ? current.params.sessionID + : undefined; + if (!currentSessionID) return undefined; + const candidates = [...(refs.get(currentSessionID) ?? [])]; + return candidates.find((candidate) => candidate.focused) ?? + candidates.at(-1); + }, + addRef(currentSessionID, value) { + const candidates = refs.get(currentSessionID) ?? new Set(); + candidates.add(value); + refs.set(currentSessionID, candidates); + }, + removeRef(currentSessionID, value) { + const candidates = refs.get(currentSessionID); + candidates?.delete(value); + if (candidates?.size === 0) refs.delete(currentSessionID); + }, + sessionID, + setSessionID, + workflows, + setWorkflows, + }; +} + +function registerComposerInputRouting( + api: TuiPluginApi, + options: MultiModelOptions, + controller: ComposerController, +) { + const route = () => { + const active = controller.ref(api.route.current); + if (!active?.focused) return false; + const routed = routeComposerPrompt( + active.current, + controller.selection(), + options.composer.autoRoute, + controller.workflows(), + options.defaultMode, + ); + if (routed !== active.current) active.set(routed); + return active; + }; + api.lifecycle.onDispose(api.keymap.intercept("key", (context) => { + if (!isPlainComposerSubmitKey(context.event)) return; + const active = route(); + if (!active) return; + context.consume(); + active.submit(); + }, { priority: 10_000 })); + api.lifecycle.onDispose(api.keymap.registerLayer({ + priority: 10_000, + commands: [{ + name: "prompt.submit", + title: "Submit prompt through multi-model composer", + hidden: true, + run() { + const active = route(); + if (!active) return false; + active.submit(); + return true; + }, + }], + })); +} + +export function isPlainComposerSubmitKey(event: { + name: string; + ctrl?: boolean; + meta?: boolean; + shift?: boolean; + option?: boolean; +}) { + return (event.name === "return" || event.name === "enter") && + !event.ctrl && !event.meta && !event.shift && !event.option; +} + +function registerComposerSlots( + api: TuiPluginApi, + store: StateStore, + options: MultiModelOptions, + controller: ComposerController, +) { + api.slots.register({ + slots: { + home_prompt(_context, props) { + return ( + + ); + }, + session_prompt(_context, props) { + return ( + + ); + }, + sidebar_content(_context, props) { + return ( + + ); + }, + sidebar_footer() { + return ( + + /runs · /graph · /workflows + + ); + }, + }, + }); +} + +function NativeComposer(props: { + api: TuiPluginApi; + store: StateStore; + options: MultiModelOptions; + controller: ComposerController; + sessionID: string; + visible?: boolean; + disabled?: boolean; + onSubmit?: () => void; + hostRef?: (ref: TuiPromptRef | undefined) => void; + rightSlot: "home_prompt_right" | "session_prompt_right"; +}) { + const Prompt = props.api.ui.Prompt; + const Slot = props.api.ui.Slot; + let ref: TuiPromptRef | undefined; + createEffect(() => { + if (!composerSlotIsActive( + props.api.route.current, + props.sessionID, + props.visible, + )) { + return; + } + props.controller.setSessionID(props.sessionID); + void props.store.getSessionMode(props.sessionID).then((stored) => { + if (props.controller.sessionID() !== props.sessionID) return; + if (!stored) { + const selection = props.controller.selection(); + void props.store.setSessionMode( + props.sessionID, + selection.mode, + selection.mode === "team" + ? selection.collaborationMode + : selection.workflowName, + ); + return; + } + props.controller.setSelection({ + mode: stored.mode, + collaborationMode: isMode(stored.collaboration_mode) + ? stored.collaboration_mode + : props.options.defaultMode, + workflowName: stored.workflow_name ?? undefined, + }); + }); + }); + onCleanup(() => { + if (ref) props.controller.removeRef(props.sessionID, ref); + props.hostRef?.(undefined); + }); + return ( + { + if (ref) props.controller.removeRef(props.sessionID, ref); + ref = value; + if (value) props.controller.addRef(props.sessionID, value); + if (!composerSlotIsActive( + props.api.route.current, + props.sessionID, + props.visible, + )) return; + props.controller.setSessionID(props.sessionID); + props.hostRef?.(value); + }} + right={ + + {props.rightSlot === "session_prompt_right" + ? + : } + + + } + /> + ); +} + +export function composerSlotIsActive( + current: TuiRouteCurrent, + sessionID: string, + visible?: boolean, +) { + if (visible === false) return false; + if (sessionID === "__home__") return current.name === "home"; + return current.name === "session" && current.params?.sessionID === sessionID; +} + +function ModeBadge(props: { + api: TuiPluginApi; + controller: ComposerController; +}) { + return ( + props.api.keymap.dispatchCommand("multimodel.mode")} + > + + {props.controller.selection().mode.toUpperCase()} + + + ); +} + +async function chooseComposerMode( + api: TuiPluginApi, + store: StateStore, + options: MultiModelOptions, + controller: ComposerController, +) { + const DialogSelect = api.ui.DialogSelect; + api.ui.dialog.replace(() => ( + ( + ({ + title: mode, + value: mode, + description: modeDescription(mode), + onSelect() { + void saveComposerSelection(store, controller, { + mode: "team", + collaborationMode: mode, + }); + api.ui.dialog.clear(); + }, + }))} + /> + )); + }, + }, + { + title: "WORKFLOW", + value: "workflow" as const, + description: "Rewrite normal prompts to a selected /workflow", + async onSelect() { + const workflows = (await store.read()).workflows; + if (workflows.length === 0) { + toast(api, "No workflows saved.", "warning"); + return; + } + api.ui.dialog.replace(() => ( + ({ + title: workflow.name, + value: workflow.name, + description: workflowSummary(workflow), + onSelect() { + void saveComposerSelection(store, controller, { + mode: "workflow", + collaborationMode: options.defaultMode, + workflowName: workflow.name, + }); + api.ui.dialog.clear(); + }, + }))} + /> + )); + }, + }, + ]} + /> + )); +} + +async function saveComposerSelection( + store: StateStore, + controller: ComposerController, + selection: ComposerSelection, +) { + controller.setSelection(selection); + await store.setSessionMode( + controller.sessionID(), + selection.mode, + selection.mode === "team" + ? selection.collaborationMode + : selection.workflowName, + ); +} + +export function routeComposerPrompt( + prompt: TuiPromptInfo, + selection: ComposerSelection, + autoRoute: boolean, + workflows: WorkflowDefinition[], + defaultMode: CollabMode, +): TuiPromptInfo { + const input = prompt.input.trim(); + if ( + !input || + prompt.mode === "shell" || + input.startsWith("/") || + input.startsWith("@") + ) return prompt; + const automatic = autoRoute ? automaticRoute(input, workflows) : undefined; + const mode = automatic?.mode ?? selection.mode; + if (mode === "single") return prompt; + if (mode === "team") { + return { + ...prompt, + input: `/collab ${automatic?.collaborationMode ?? selection.collaborationMode ?? defaultMode} ${prompt.input}`, + }; + } + const workflowName = automatic?.workflowName ?? selection.workflowName; + if (!workflowName) return prompt; + return { ...prompt, input: `/workflow ${workflowName} ${prompt.input}` }; +} + +function automaticRoute(input: string, workflows: WorkflowDefinition[]) { + const workflow = workflows.find((definition) => { + const name = escapeRegExp(definition.name); + return new RegExp(`(?:^|\\s)workflow(?::|\\s+)${name}(?=\\s|$)`, "i").test(input); + }); + if (workflow) { + return { mode: "workflow" as const, workflowName: workflow.name }; + } + if ( + /\b(multi[- ]model|multiple models|team|council|jury|panel|collaborat(?:e|ion))\b/i.test(input) + ) { + return { mode: "team" as const, collaborationMode: "council" as const }; + } + return undefined; +} + +function watchBackgroundRuns( + api: TuiPluginApi, + store: StateStore, + composer: ComposerController, +) { + const seen = new Map(); + const watchingSince = Date.now(); + let timer: ReturnType | undefined; + let disposed = false; + const poll = async () => { + if (disposed || api.lifecycle.signal.aborted) return; + const state = await store.read(); + composer.setWorkflows(state.workflows); + const runs = state.runs.filter((run) => run.background); + await Promise.all(runs.map(async (run) => { + const previous = seen.get(run.id); + seen.set(run.id, run.status); + const newlyCompleted = !previous && run.createdAt >= watchingSince && + !isActiveStatus(run.status); + const transitioned = !!previous && isActiveStatus(previous) && + !isActiveStatus(run.status); + if (!newlyCompleted && !transitioned) return; + await api.attention.notify({ + title: "Multi-model run complete", + message: `${run.definition} · ${run.status}`, + sound: { name: run.status === "completed" ? "done" : "error" }, + notification: true, + }); + })); + if (disposed || api.lifecycle.signal.aborted) return; + timer = setTimeout( + () => void poll(), + runs.some((run) => isActiveStatus(run.status)) ? 500 : 2_000, + ); + }; + void poll(); + api.lifecycle.onDispose(() => { + disposed = true; + if (timer) clearTimeout(timer); + }); +} + +function usePollingState(store: StateStore) { + const [state, setState] = createSignal>>(); + let timer: ReturnType | undefined; + let disposed = false; + onMount(() => { + const poll = async () => { + const next = await store.read(); + if (disposed) return; + setState(next); + timer = setTimeout( + () => void poll(), + next.runs.some((run) => isActiveStatus(run.status)) ? 500 : 2_000, + ); + }; + void poll(); + }); + onCleanup(() => { + disposed = true; + if (timer) clearTimeout(timer); + }); + return state; +} + +function SidebarStatus(props: { + api: TuiPluginApi; + store: StateStore; + sessionID: string; +}) { + const state = usePollingState(props.store); + return ( + + {(value) => ( + + MULTI-MODEL + + lead={value().fleet.leadID} · fleet={value().fleet.members.filter((member) => member.enabled).length} + + + run.sessionID === props.sessionID && isActiveStatus(run.status) + )}> + {(run) => ( + + {run.definition} · {run.status} + + )} + + + )} + + ); +} + +function isActiveStatus(status: string) { + return status === "pending" || status === "running" || status === "paused"; +} + +function runColor(api: TuiPluginApi, status: string) { + if (status === "completed") return api.theme.current.success; + if (status === "failed" || status === "cancelled" || status === "stopped") { + return api.theme.current.error; + } + if (status === "running") return api.theme.current.info; + return api.theme.current.warning; +} + +function workflowSummary(workflow: WorkflowDefinition) { + return workflow.kind === "script" + ? "confined script" + : `${workflow.steps.length} DAG steps`; +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + type ScreenProps = { api: TuiPluginApi; store: StateStore; @@ -509,7 +1254,11 @@ async function selectCollaboration( )); } -async function selectWorkflow(api: TuiPluginApi, store: StateStore) { +async function selectWorkflow( + api: TuiPluginApi, + store: StateStore, + composer: ComposerController, +) { const state = await store.read(); if (state.workflows.length === 0) return toast(api, "No workflows saved.", "warning"); @@ -521,8 +1270,26 @@ async function selectWorkflow(api: TuiPluginApi, store: StateStore) { options={state.workflows.map((definition) => ({ title: definition.name, value: definition.name, - description: `${definition.steps.length} steps · ${definition.description ?? "No description"}`, + description: `${workflowSummary(definition)} · ${definition.description ?? "No description"}`, onSelect() { + if (definition.kind === "script") { + const prompt = composer.ref(api.route.current); + if (!prompt) { + toast( + api, + `Use /workflow ${definition.name} from the native composer to approve this script.`, + "warning", + ); + return; + } + prompt.set({ + ...prompt.current, + input: `/workflow ${definition.name} `, + }); + prompt.focus(); + api.ui.dialog.clear(); + return; + } askForInput( api, `${definition.name} input`, diff --git a/packages/opencode-multimodel/src/types.ts b/packages/opencode-multimodel/src/types.ts index aa0160d..6d2d327 100644 --- a/packages/opencode-multimodel/src/types.ts +++ b/packages/opencode-multimodel/src/types.ts @@ -11,6 +11,8 @@ export const COLLAB_MODES = [ ] as const; export type CollabMode = (typeof COLLAB_MODES)[number]; +export type ComposerMode = "single" | "team" | "workflow"; +export type MemberIsolation = "shared" | "worktree"; export type ModelRef = { providerID: string; @@ -24,6 +26,7 @@ export type FleetMember = { agent?: string; system?: string; enabled: boolean; + isolation?: MemberIsolation; }; export type Fleet = { @@ -107,6 +110,7 @@ export type CollaborateOptions = { handoffTo?: string; juryRounds?: 1 | 2; signal?: AbortSignal; + runID?: string; onActivity?: (event: CollabActivity) => void; }; @@ -116,11 +120,16 @@ export type RunAgentInput = { prompt: string; system?: string; signal?: AbortSignal; + runID?: string; + stepID?: string; + callIndex?: number; }; export interface AgentRunner { run(input: RunAgentInput): Promise; - cancel?(parentSessionID: string): Promise; + cancel?(parentSessionID: string, runID?: string): Promise; + steer?(parentSessionID: string, prompt: string, runID?: string): Promise; + cleanupWorkspaces?(runID?: string): Promise; close?(): Promise; } @@ -134,7 +143,8 @@ export type WorkflowStep = { continueOnError?: boolean; }; -export type WorkflowDefinition = { +export type DagWorkflowDefinition = { + kind?: "dag"; name: string; description?: string; maxParallel?: number; @@ -142,9 +152,27 @@ export type WorkflowDefinition = { steps: WorkflowStep[]; }; +export type ScriptWorkflowDefinition = { + kind: "script"; + name: string; + description?: string; + source: string; + sourceHash?: string; +}; + +export type WorkflowDefinition = + | DagWorkflowDefinition + | ScriptWorkflowDefinition; + export type WorkflowStepRun = { id: string; - status: "pending" | "running" | "completed" | "failed" | "cancelled"; + status: + | "pending" + | "running" + | "completed" + | "failed" + | "cancelled" + | "interrupted"; memberID: string; output?: string; error?: string; @@ -152,27 +180,100 @@ export type WorkflowStepRun = { completedAt?: number; }; +export type RunStatus = + | "pending" + | "running" + | "paused" + | "completed" + | "failed" + | "cancelled" + | "stopped" + | "interrupted"; + export type WorkflowRun = { id: string; + kind: "workflow"; + definition: string; + workflowKind: "dag" | "script"; + sessionID: string; + messageID?: string; + input: string; + status: RunStatus; + steps: WorkflowStepRun[]; + final?: string; + error?: string; + background?: boolean; + sourceHash?: string; + createdAt: number; + updatedAt: number; +}; + +export type CollaborationRun = { + id: string; + kind: "collaboration"; definition: string; sessionID: string; + messageID?: string; input: string; - status: "pending" | "running" | "completed" | "failed" | "cancelled"; + status: RunStatus; + mode: CollabMode; + participants: string[]; steps: WorkflowStepRun[]; final?: string; error?: string; + background?: boolean; createdAt: number; updatedAt: number; }; +export type DurableRun = WorkflowRun | CollaborationRun; + export type WorkflowRunOptions = { signal?: AbortSignal; + run?: WorkflowRun; + runID?: string; + messageID?: string; + background?: boolean; + maxAgentCalls?: number; + maxParallel?: number; + timeoutMs?: number; + beforeStep?: (run: WorkflowRun) => void | Promise; onUpdate?: (run: WorkflowRun) => void | Promise; }; +export type LedgerEvent = { + id: number; + runID?: string; + type: string; + data: unknown; + createdAt: number; +}; + +export type WorkspaceRecord = { + id: string; + runID?: string; + memberID: string; + directory?: string; + status: "active" | "preserved" | "removed" | "failed"; + createdAt: number; + updatedAt: number; +}; + export type PersistedState = { - version: 1; + version: 2; fleet: Fleet; workflows: WorkflowDefinition[]; - runs: WorkflowRun[]; + runs: DurableRun[]; + events: LedgerEvent[]; + workspaces: WorkspaceRecord[]; }; + +export function isDagWorkflow( + definition: WorkflowDefinition, +): definition is DagWorkflowDefinition { + return definition.kind !== "script"; +} + +export function isWorkflowRun(run: DurableRun): run is WorkflowRun { + return run.kind === "workflow"; +} diff --git a/packages/opencode-multimodel/src/workflow-files.ts b/packages/opencode-multimodel/src/workflow-files.ts new file mode 100644 index 0000000..069e79a --- /dev/null +++ b/packages/opencode-multimodel/src/workflow-files.ts @@ -0,0 +1,88 @@ +import { stat } from "node:fs/promises"; +import { basename, isAbsolute, resolve } from "node:path"; +import type { MultiModelOptions } from "./options.ts"; +import { validateWorkflowScript } from "./script.ts"; +import type { StateStore } from "./state.ts"; +import type { WorkflowDefinition } from "./types.ts"; +import { validateWorkflow } from "./workflow.ts"; + +export async function loadWorkflowDirectories( + store: StateStore, + directory: string, + options: MultiModelOptions["workflows"], +) { + const files: string[] = []; + for (const configured of options.directories) { + const root = isAbsolute(configured) ? configured : resolve(directory, configured); + const directoryExists = await stat(root).then( + (entry) => entry.isDirectory(), + () => false, + ); + if (!directoryExists) continue; + for await (const file of new Bun.Glob("**/*.{json,js,ts}").scan({ + cwd: root, + absolute: true, + onlyFiles: true, + })) files.push(file); + } + for (const file of files.sort()) { + if (file.endsWith(".json")) { + const definition = parseWorkflowDefinition(await Bun.file(file).text()); + if (definition.kind === "script") { + if (!options.scripts) continue; + definition.sourceHash = validateWorkflowScript(definition.source).sourceHash; + } else { + validateWorkflow(definition); + } + await store.saveWorkflow(definition); + continue; + } + if (!options.scripts) continue; + const source = await Bun.file(file).text(); + const definition = { + kind: "script" as const, + name: scriptName(source) ?? basename(file).replace(/\.(?:js|ts)$/, ""), + source, + sourceHash: validateWorkflowScript(source).sourceHash, + }; + await store.saveWorkflow(definition); + } + return files.length; +} + +export function parseWorkflowDefinition(value: string): WorkflowDefinition { + const parsed: unknown = JSON.parse(value); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Workflow definition must be a JSON object."); + } + const input = parsed as Record; + if (typeof input.name !== "string" || !input.name.trim()) { + throw new Error("Workflow name is required."); + } + if (input.kind === "script") { + if (typeof input.source !== "string") { + throw new Error("Script workflow source is required."); + } + return { + kind: "script", + name: input.name, + description: typeof input.description === "string" + ? input.description + : undefined, + source: input.source, + }; + } + if (input.kind !== undefined && input.kind !== "dag") { + throw new Error('Workflow kind must be "dag" or "script".'); + } + if (!Array.isArray(input.steps)) { + throw new Error("DAG workflow steps are required."); + } + return { ...input, kind: "dag" } as WorkflowDefinition; +} + +function scriptName(source: string) { + return source.match( + /export\s+const\s+meta\s*=\s*\{[\s\S]*?\bname\s*:\s*["']([^"']+)["']/m, + )?.[1]; +} diff --git a/packages/opencode-multimodel/src/workflow.ts b/packages/opencode-multimodel/src/workflow.ts index 054d167..1b6f73d 100644 --- a/packages/opencode-multimodel/src/workflow.ts +++ b/packages/opencode-multimodel/src/workflow.ts @@ -2,9 +2,9 @@ import { mapLimit } from "./concurrency.ts"; import { collaborationSystem } from "./prompts.ts"; import type { AgentRunner, + DagWorkflowDefinition, Fleet, FleetMember, - WorkflowDefinition, WorkflowRun, WorkflowRunOptions, WorkflowStep, @@ -21,15 +21,18 @@ export class WorkflowValidationError extends Error { } } -export function validateWorkflow(definition: WorkflowDefinition) { - if (!definition.name.trim()) +export function validateWorkflow(definition: DagWorkflowDefinition) { + if (!definition.name.trim()) { throw new WorkflowValidationError("Workflow name is required."); - if (definition.steps.length === 0) + } + if (definition.steps.length === 0) { throw new WorkflowValidationError( "Workflow must contain at least one step.", ); - if (definition.steps.length > MAX_STEPS) + } + if (definition.steps.length > MAX_STEPS) { throw new WorkflowValidationError(`Workflow exceeds ${MAX_STEPS} steps.`); + } const ids = new Set(); definition.steps.forEach((step) => { if (!/^[a-zA-Z0-9_-]+$/.test(step.id)) { @@ -37,23 +40,27 @@ export function validateWorkflow(definition: WorkflowDefinition) { `Invalid step id ${step.id}. Use letters, numbers, _ or -.`, ); } - if (ids.has(step.id)) + if (ids.has(step.id)) { throw new WorkflowValidationError(`Duplicate step id ${step.id}.`); - if (!step.prompt.trim()) + } + if (!step.prompt.trim()) { throw new WorkflowValidationError(`Step ${step.id} has an empty prompt.`); + } ids.add(step.id); }); definition.steps.forEach((step) => (step.needs ?? []).forEach((dependency) => { - if (!ids.has(dependency)) + if (!ids.has(dependency)) { throw new WorkflowValidationError( `Step ${step.id} needs missing step ${dependency}.`, ); - if (dependency === step.id) + } + if (dependency === step.id) { throw new WorkflowValidationError( `Step ${step.id} cannot depend on itself.`, ); - }), + } + }) ); const remaining = new Set(ids); while (remaining.size > 0) { @@ -63,10 +70,11 @@ export function validateWorkflow(definition: WorkflowDefinition) { (dependency) => !remaining.has(dependency), ); }); - if (ready.length === 0) + if (ready.length === 0) { throw new WorkflowValidationError( "Workflow contains a dependency cycle.", ); + } ready.forEach((id) => remaining.delete(id)); } return definition; @@ -76,7 +84,7 @@ export async function runWorkflow( runner: AgentRunner, fleet: Fleet, parentSessionID: string, - definition: WorkflowDefinition, + definition: DagWorkflowDefinition, input: string, options: WorkflowRunOptions = {}, ) { @@ -84,39 +92,54 @@ export async function runWorkflow( const lead = fleet.members.find( (member) => member.id === fleet.leadID && member.enabled, ); - if (!lead) + if (!lead) { throw new Error(`Fleet lead ${fleet.leadID} is missing or disabled.`); + } const createdAt = Date.now(); - const run: WorkflowRun = { - id: `workflow_${crypto.randomUUID()}`, - definition: definition.name, - sessionID: parentSessionID, - input, - status: "pending", - steps: definition.steps.map((step) => ({ - id: step.id, + const run: WorkflowRun = options.run + ? structuredClone(options.run) + : { + id: options.runID ?? `workflow_${crypto.randomUUID()}`, + kind: "workflow", + workflowKind: "dag", + definition: definition.name, + sessionID: parentSessionID, + messageID: options.messageID, + input, status: "pending", - memberID: step.memberID ?? lead.id, - })), - createdAt, - updatedAt: createdAt, - }; + background: options.background, + steps: definition.steps.map((step) => ({ + id: step.id, + status: "pending", + memberID: step.memberID ?? lead.id, + })), + createdAt, + updatedAt: createdAt, + }; + run.steps.forEach((step) => { + if (step.status !== "running" && step.status !== "interrupted") return; + step.status = "pending"; + step.error = undefined; + step.startedAt = undefined; + step.completedAt = undefined; + }); await update(run, options, () => { run.status = "running"; + run.error = undefined; }); try { while (run.steps.some((step) => step.status === "pending")) { + options.signal?.throwIfAborted(); + await options.beforeStep?.(structuredClone(run)); options.signal?.throwIfAborted(); cancelBlockedSteps(run, definition); const ready = definition.steps.filter((step) => { const state = findRunStep(run, step.id); - return ( - state.status === "pending" && + return state.status === "pending" && (step.needs ?? []).every((dependency) => - isTerminal(findRunStep(run, dependency)), - ) - ); + isTerminal(findRunStep(run, dependency)) + ); }); if ( ready.length === 0 && @@ -126,19 +149,22 @@ export async function runWorkflow( } await mapLimit( ready, - Math.min(Math.max(1, definition.maxParallel ?? 3), MAX_PARALLEL), - (step) => - executeStep( - runner, - fleet, - lead, - parentSessionID, - definition, - step, - run, - options, - ), + Math.min( + Math.max(1, options.maxParallel ?? definition.maxParallel ?? 3), + MAX_PARALLEL, + ), + (step) => executeStep( + runner, + fleet, + lead, + parentSessionID, + definition, + step, + run, + options, + ), ); + options.signal?.throwIfAborted(); const hardFailure = definition.steps.find((step) => { const state = findRunStep(run, step.id); return state.status === "failed" && step.continueOnError !== true; @@ -161,15 +187,15 @@ export async function runWorkflow( if (definition.synthesize) { options.signal?.throwIfAborted(); + await options.beforeStep?.(structuredClone(run)); const response = await runner.run({ parentSessionID, member: lead, prompt: [ `Workflow **${definition.name}** input:\n${input}`, "Step results:", - ...run.steps.map( - (step) => - `### ${step.id} (${step.status})\n${step.output ?? step.error ?? "No output"}`, + ...run.steps.map((step) => + `### ${step.id} (${step.status})\n${step.output ?? step.error ?? "No output"}` ), "As LEAD, synthesize the final workflow result for the user.", ].join("\n\n"), @@ -179,6 +205,9 @@ export async function runWorkflow( fleet.members.filter((member) => member.enabled), ), signal: options.signal, + runID: run.id, + stepID: "__synthesize", + callIndex: definition.steps.length, }); await update(run, options, () => { run.final = response.text; @@ -186,10 +215,9 @@ export async function runWorkflow( } await update(run, options, () => { - run.status = definition.steps.some( - (step) => + run.status = definition.steps.some((step) => findRunStep(run, step.id).status === "failed" && - step.continueOnError !== true, + step.continueOnError !== true ) ? "failed" : "completed"; @@ -202,16 +230,14 @@ export async function runWorkflow( run.status = cancelled ? "cancelled" : "failed"; run.error = error instanceof Error ? error.message : String(error); run.steps - .filter( - (step) => step.status === "pending" || step.status === "running", - ) + .filter((step) => step.status === "pending" || step.status === "running") .forEach((step) => { step.status = "cancelled"; step.error = cancelled ? "Workflow cancelled." : "Workflow stopped."; step.completedAt = Date.now(); }); }); - if (cancelled) await runner.cancel?.(parentSessionID); + if (cancelled) await runner.cancel?.(parentSessionID, run.id); return run; } } @@ -221,7 +247,7 @@ async function executeStep( fleet: Fleet, lead: FleetMember, parentSessionID: string, - definition: WorkflowDefinition, + definition: DagWorkflowDefinition, step: WorkflowStep, run: WorkflowRun, options: WorkflowRunOptions, @@ -246,16 +272,20 @@ async function executeStep( `You are executing declarative workflow **${definition.name}**, step **${step.id}**. Return only this step's concrete result.`, ].join("\n\n"), signal: options.signal, + runID: run.id, + stepID: step.id, + callIndex: definition.steps.findIndex((item) => item.id === step.id), }); await update(run, options, () => { state.status = "completed"; state.memberID = member.id; state.output = response.text; + state.error = undefined; state.completedAt = Date.now(); }); } catch (error) { await update(run, options, () => { - state.status = "failed"; + state.status = options.signal?.aborted ? "cancelled" : "failed"; state.memberID = member.id; state.error = error instanceof Error ? error.message : String(error); state.completedAt = Date.now(); @@ -265,14 +295,15 @@ async function executeStep( function workflowMember(fleet: Fleet, lead: FleetMember, step: WorkflowStep) { const base = step.memberID - ? fleet.members.find( - (member) => member.id === step.memberID && member.enabled, - ) + ? fleet.members.find((member) => + member.id === step.memberID && member.enabled + ) : lead; - if (!base) + if (!base) { throw new Error( `Workflow step ${step.id} selects missing or disabled member ${step.memberID}.`, ); + } if (!step.model && !step.agent) return base; return { ...base, @@ -284,8 +315,8 @@ function workflowMember(fleet: Fleet, lead: FleetMember, step: WorkflowStep) { function inputValues(run: WorkflowRun) { return Object.fromEntries([ ["input", run.input], - ...run.steps.map( - (step) => [step.id, step.output ?? step.error ?? ""] as const, + ...run.steps.map((step) => + [step.id, step.output ?? step.error ?? ""] as const ), ]); } @@ -297,7 +328,10 @@ export function interpolate(template: string, values: Record) { ); } -function cancelBlockedSteps(run: WorkflowRun, definition: WorkflowDefinition) { +function cancelBlockedSteps( + run: WorkflowRun, + definition: DagWorkflowDefinition, +) { definition.steps.forEach((step) => { const state = findRunStep(run, step.id); if (state.status !== "pending") return; @@ -324,11 +358,9 @@ function findRunStep(run: WorkflowRun, id: string): WorkflowStepRun { } function isTerminal(step: WorkflowStepRun) { - return ( - step.status === "completed" || + return step.status === "completed" || step.status === "failed" || - step.status === "cancelled" - ); + step.status === "cancelled"; } async function update( diff --git a/packages/opencode-multimodel/tests/collaborate.test.ts b/packages/opencode-multimodel/tests/collaborate.test.ts index 4e06ba2..d304f6c 100644 --- a/packages/opencode-multimodel/tests/collaborate.test.ts +++ b/packages/opencode-multimodel/tests/collaborate.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { collaborate, parseTasksBlock } from "../src/collaborate.ts"; +import { COLLAB_MODES } from "../src/types.ts"; import type { AgentReply, AgentRunner, @@ -64,6 +65,38 @@ class FakeRunner implements AgentRunner { } describe("Poly-derived collaboration modes", () => { + for (const mode of COLLAB_MODES) { + test(`${mode} completes with its declared routing semantics`, async () => { + const runner = new FakeRunner((input) => [ + `POSITION: ${input.member.id} position`, + "VOTE: approve", + "CONFIDENCE: high", + "RATIONALE: Evidence", + "RISKS: None", + "ALTERNATIVE: None", + ].join("\n")); + const result = await collaborate(runner, fleet, "parent", "Smoke test", { + mode, + handoffTo: "codex", + juryRounds: 1, + }); + + expect(result.mode).toBe(mode); + expect(result.final.text.length).toBeGreaterThan(0); + expect(runner.calls.length).toBeGreaterThan(0); + }); + } + + test("lead records only the member that actually ran", async () => { + const runner = new FakeRunner(() => "lead answer"); + const result = await collaborate(runner, fleet, "parent", "Answer", { + mode: "lead", + }); + + expect(runner.calls.map((call) => call.member.id)).toEqual(["lead"]); + expect(result.participants).toEqual(["lead"]); + }); + test("pair runs lead plan, worker response, and lead synthesis", async () => { const runner = new FakeRunner((input, index) => index === 2 ? "final answer" : `${input.member.id} reply`, diff --git a/packages/opencode-multimodel/tests/composer.test.ts b/packages/opencode-multimodel/tests/composer.test.ts new file mode 100644 index 0000000..d5453df --- /dev/null +++ b/packages/opencode-multimodel/tests/composer.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, test } from "bun:test"; +import type { TuiPromptInfo } from "@opencode-ai/plugin/tui"; +import { parseOptions } from "../src/options.ts"; +import { + composerSlotIsActive, + isPlainComposerSubmitKey, + routeComposerPrompt, +} from "../src/tui.tsx"; +import type { WorkflowDefinition } from "../src/types.ts"; + +const workflows: WorkflowDefinition[] = [{ + kind: "dag", + name: "release", + steps: [{ id: "ship", prompt: "Ship ${input}" }], +}]; + +describe("native composer routing", () => { + test("keeps SINGLE input and attachment objects unchanged", () => { + const prompt = value("Review this", [{ type: "file", mime: "text/plain", url: "file:///a" }]); + expect(routeComposerPrompt( + prompt, + { mode: "single", collaborationMode: "council" }, + false, + workflows, + "council", + )).toBe(prompt); + }); + + test("routes TEAM and WORKFLOW while preserving attachments", () => { + const prompt = value("Review this", [{ type: "agent", name: "reviewer" }]); + const team = routeComposerPrompt( + prompt, + { mode: "team", collaborationMode: "jury" }, + false, + workflows, + "council", + ); + const workflow = routeComposerPrompt( + prompt, + { mode: "workflow", collaborationMode: "council", workflowName: "release" }, + false, + workflows, + "council", + ); + + expect(team.input).toBe("/collab jury Review this"); + expect(team.parts).toBe(prompt.parts); + expect(workflow.input).toBe("/workflow release Review this"); + expect(workflow.parts).toBe(prompt.parts); + }); + + test("never rewrites shell, slash-command, or @ input", () => { + const selection = { mode: "team" as const, collaborationMode: "jury" as const }; + for (const prompt of [ + value("ls", [], "shell"), + value("/help"), + value("@reviewer inspect this"), + ]) { + expect(routeComposerPrompt( + prompt, + selection, + true, + workflows, + "council", + )).toBe(prompt); + } + }); + + test("auto-routing requires explicit team language or an exact workflow reference", () => { + const selection = { mode: "single" as const, collaborationMode: "pair" as const }; + expect(routeComposerPrompt( + value("Ask multiple models to assess this"), + selection, + true, + workflows, + "pair", + ).input).toStartWith("/collab council "); + expect(routeComposerPrompt( + value("Run workflow:release for version 2"), + selection, + true, + workflows, + "pair", + ).input).toStartWith("/workflow release "); + expect(routeComposerPrompt( + value("Make the release workflow better"), + selection, + true, + workflows, + "pair", + ).input).toBe("Make the release workflow better"); + }); + + test("only lets the composer for the current route own the shared prompt ref", () => { + expect(composerSlotIsActive({ name: "home" }, "__home__")).toBe(true); + expect(composerSlotIsActive({ name: "home" }, "session-a")).toBe(false); + expect(composerSlotIsActive( + { name: "session", params: { sessionID: "session-a" } }, + "__home__", + )).toBe(false); + expect(composerSlotIsActive( + { name: "session", params: { sessionID: "session-a" } }, + "session-a", + )).toBe(true); + expect(composerSlotIsActive( + { name: "session", params: { sessionID: "session-a" } }, + "session-a", + false, + )).toBe(false); + }); + + test("takes over only an unmodified Enter key for native submission", () => { + expect(isPlainComposerSubmitKey({ name: "return" })).toBe(true); + expect(isPlainComposerSubmitKey({ name: "enter" })).toBe(true); + expect(isPlainComposerSubmitKey({ name: "return", shift: true })).toBe(false); + expect(isPlainComposerSubmitKey({ name: "enter", ctrl: true })).toBe(false); + expect(isPlainComposerSubmitKey({ name: "tab" })).toBe(false); + }); +}); + +describe("configuration validation", () => { + test("accepts the documented nested configuration", () => { + expect(parseOptions({ + databasePath: ".opencode/custom.sqlite", + composer: { enabled: true, initial: "workflow", autoRoute: true }, + workflows: { + scripts: true, + directories: [".opencode/workflows"], + timeoutMs: 1_000, + maxAgentCalls: 4, + }, + retention: { runs: 25, events: 500 }, + })).toMatchObject({ + databasePath: ".opencode/custom.sqlite", + composer: { initial: "workflow", autoRoute: true }, + workflows: { scripts: true, maxAgentCalls: 4 }, + retention: { runs: 25, events: 500 }, + }); + }); + + test("rejects unknown and invalid options instead of falling back", () => { + expect(() => parseOptions({ typo: true })).toThrow("typo is not a supported option"); + expect(() => parseOptions({ maxParallel: 99 })).toThrow("maxParallel"); + expect(() => parseOptions({ composer: { initial: "magic" } })).toThrow("composer.initial"); + expect(() => parseOptions({ + fleet: { + leadID: "lead", + members: [{ + id: "lead", + role: "lead", + model: { providerID: "test", modelID: "model", token: "secret" }, + enabled: true, + }], + }, + })).toThrow("fleet.members[0].model.token is not a supported option"); + }); +}); + +function value( + input: string, + parts: unknown[] = [], + mode: "normal" | "shell" = "normal", +): TuiPromptInfo { + return { input, mode, parts } as TuiPromptInfo; +} diff --git a/packages/opencode-multimodel/tests/opencode.test.ts b/packages/opencode-multimodel/tests/opencode.test.ts index f50cccd..bb2c138 100644 --- a/packages/opencode-multimodel/tests/opencode.test.ts +++ b/packages/opencode-multimodel/tests/opencode.test.ts @@ -1,9 +1,13 @@ import { describe, expect, test } from "bun:test"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { asOpenCodeClient, discoverFleet, OpenCodeAgentRunner, } from "../src/opencode.ts"; +import { StateStore } from "../src/state.ts"; import type { FleetMember } from "../src/types.ts"; const member: FleetMember = { @@ -55,7 +59,14 @@ describe("OpenCode child-session adapter", () => { expect(prompted[0]?.tools).toEqual({ multimodel_collab: false, multimodel_fleet: false, + multimodel_run: false, multimodel_workflow: false, + codex_delegate: false, + codex_review: false, + codex_status: false, + codex_steer: false, + codex_cancel: false, + codex_probe: false, }); }); @@ -76,8 +87,12 @@ describe("OpenCode child-session adapter", () => { async list() { return { data: { - connected: ["anthropic", "codex-delegate"], - default: { anthropic: "claude", "codex-delegate": "gpt-codex" }, + connected: ["codex-delegate", "anthropic", "xai"], + default: { + anthropic: "claude", + "codex-delegate": "gpt-codex", + xai: "video", + }, all: [ { id: "anthropic", @@ -94,6 +109,22 @@ describe("OpenCode child-session adapter", () => { name: "Offline", models: { model: { id: "model", name: "Model" } }, }, + { + id: "xai", + name: "xAI", + models: { + video: { + id: "video", + name: "Video", + capabilities: { output: { text: false } }, + }, + grok: { + id: "grok", + name: "Grok", + capabilities: { output: { text: true } }, + }, + }, + }, ], }, }; @@ -103,9 +134,187 @@ describe("OpenCode child-session adapter", () => { const discovered = await discoverFleet(client); expect(discovered.members.map((item) => item.model.providerID)).toEqual([ - "anthropic", "codex-delegate", + "anthropic", + "xai", ]); - expect(discovered.members[1]?.model.modelID).toBe("gpt-codex"); + expect(discovered.members[0]?.model.modelID).toBe("gpt-codex"); + expect(discovered.members[2]?.model.modelID).toBe("grok"); + }); + + test("reuses persisted child sessions after a runner restart", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-child-restart-")); + const store = new StateStore(join(directory, "state.sqlite")); + let creates = 0; + const client = asOpenCodeClient({ + session: { + async create() { + creates += 1; + return { data: { id: `child-${creates}` } }; + }, + async prompt() { + return { data: { parts: [{ type: "text", text: "answer" }] } }; + }, + async abort() { + return { data: true }; + }, + }, + }); + const first = new OpenCodeAgentRunner(client, store); + await first.run({ parentSessionID: "parent", member, prompt: "one" }); + await first.close(); + const second = new OpenCodeAgentRunner(client, store); + const reply = await second.run({ + parentSessionID: "parent", + member, + prompt: "two", + }); + + expect(creates).toBe(1); + expect(reply.sessionID).toBe("child-1"); + await second.close(); + await store.close(); + }); + + test("never falls back to shared checkout when worktree creation fails", async () => { + let creates = 0; + const runner = new OpenCodeAgentRunner(asOpenCodeClient({ + experimental: { + workspace: { + async create() { + return { error: "workspace unavailable" }; + }, + async remove() { + return { data: true }; + }, + }, + }, + session: { + async create() { + creates += 1; + return { data: { id: "child" } }; + }, + async prompt() { + return { data: { parts: [{ type: "text", text: "answer" }] } }; + }, + async abort() { + return { data: true }; + }, + }, + })); + + await expect(runner.run({ + parentSessionID: "parent", + member: { ...member, isolation: "worktree", model: { providerID: "test", modelID: "model" } }, + prompt: "write", + })).rejects.toThrow("Shared-checkout fallback is forbidden"); + expect(creates).toBe(0); + }); + + test("preserves successful worktrees until explicit cleanup", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-workspace-cleanup-")); + const store = new StateStore(join(directory, "state.sqlite")); + const removed: string[] = []; + const runner = new OpenCodeAgentRunner(asOpenCodeClient({ + experimental: { + workspace: { + async create() { + return { data: { id: "workspace-1", directory: "/tmp/workspace-1" } }; + }, + async remove(input: { id: string }) { + removed.push(input.id); + return { data: true }; + }, + }, + }, + session: { + async create() { + return { data: { id: "child" } }; + }, + async prompt() { + return { data: { parts: [{ type: "text", text: "answer" }] } }; + }, + async abort() { + return { data: true }; + }, + }, + }), store); + await runner.run({ + parentSessionID: "parent", + runID: "run", + member: { ...member, isolation: "worktree", model: { providerID: "test", modelID: "model" } }, + prompt: "write", + }); + + expect((await store.listWorkspaces("run"))[0]?.status).toBe("preserved"); + expect(removed).toEqual([]); + expect(await runner.cleanupWorkspaces("run")).toBe(1); + expect(removed).toEqual(["workspace-1"]); + await store.close(); + }); + + test("separates worktrees by run and invalidates child reuse after cleanup", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-workspace-runs-")); + const store = new StateStore(join(directory, "state.sqlite")); + let workspaces = 0; + let sessions = 0; + const runner = new OpenCodeAgentRunner(asOpenCodeClient({ + experimental: { + workspace: { + async create() { + workspaces += 1; + return { data: { id: `workspace-${workspaces}` } }; + }, + async remove() { + return { data: true }; + }, + }, + }, + session: { + async create() { + sessions += 1; + return { data: { id: `child-${sessions}` } }; + }, + async prompt() { + return { data: { parts: [{ type: "text", text: "answer" }] } }; + }, + async abort() { + return { data: true }; + }, + }, + }), store); + const isolated = { + ...member, + isolation: "worktree" as const, + model: { providerID: "test", modelID: "model" }, + }; + + await runner.run({ + parentSessionID: "parent", + runID: "run-a", + member: isolated, + prompt: "one", + }); + await runner.run({ + parentSessionID: "parent", + runID: "run-b", + member: isolated, + prompt: "two", + }); + expect(workspaces).toBe(2); + expect(sessions).toBe(2); + + await runner.cleanupWorkspaces("run-a"); + await runner.run({ + parentSessionID: "parent", + runID: "run-a", + member: isolated, + prompt: "three", + }); + expect(workspaces).toBe(3); + expect(sessions).toBe(3); + expect((await store.listWorkspaces("run-a")).map((item) => item.status)) + .toEqual(["removed", "preserved"]); + await store.close(); }); }); diff --git a/packages/opencode-multimodel/tests/orchestration.test.ts b/packages/opencode-multimodel/tests/orchestration.test.ts new file mode 100644 index 0000000..1f8ac4a --- /dev/null +++ b/packages/opencode-multimodel/tests/orchestration.test.ts @@ -0,0 +1,179 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { RunService } from "../src/orchestration.ts"; +import { parseOptions } from "../src/options.ts"; +import { StateStore } from "../src/state.ts"; +import type { AgentRunner, Fleet } from "../src/types.ts"; + +const cleanup: Array<() => Promise> = []; + +afterEach(async () => { + await Promise.allSettled(cleanup.splice(0).map((close) => close())); +}); + +describe("durable run control", () => { + test("cancels a background collaboration and its active child", async () => { + const cancelled: Array = []; + const runner: AgentRunner = { + async run(input) { + return new Promise((_resolve, reject) => { + input.signal?.addEventListener("abort", () => reject(input.signal?.reason), { + once: true, + }); + }); + }, + async cancel(_sessionID, runID) { + cancelled.push(runID); + }, + }; + const { store, service } = await setup(runner); + const admitted = await service.startCollaboration({ + sessionID: "parent", + messageID: "message", + prompt: "Question", + mode: "lead", + background: true, + }); + await waitFor(async () => (await store.getRun(admitted.id))?.status === "running"); + await service.cancel(admitted.id); + await waitFor(async () => (await store.getRun(admitted.id))?.status === "cancelled"); + + expect(cancelled).toContain(admitted.id); + expect((await store.getRun(admitted.id))?.status).toBe("cancelled"); + }); + + test("pauses DAG workflows only between agent calls and resumes in-process", async () => { + const releases: Array<() => void> = []; + const calls: string[] = []; + const runner: AgentRunner = { + async run(input) { + calls.push(input.prompt); + await new Promise((resolve, reject) => { + releases.push(resolve); + input.signal?.addEventListener("abort", () => reject(input.signal?.reason), { + once: true, + }); + }); + return { + memberID: input.member.id, + sessionID: `child-${calls.length}`, + model: input.member.model, + text: `result-${calls.length}`, + }; + }, + }; + const { store, service } = await setup(runner); + const admitted = await service.startWorkflow({ + sessionID: "parent", + messageID: "message", + definition: { + kind: "dag", + name: "two-step", + steps: [ + { id: "one", prompt: "one" }, + { id: "two", needs: ["one"], prompt: "two ${one}" }, + ], + }, + input: "", + background: true, + }); + await waitFor(() => calls.length === 1); + await service.pause(admitted.id); + releases[0]!(); + await waitFor(async () => (await store.getRun(admitted.id))?.status === "paused"); + expect(calls).toEqual(["one"]); + + await service.resume(admitted.id); + await waitFor(() => calls.length === 2); + releases[1]!(); + await waitFor(async () => (await store.getRun(admitted.id))?.status === "completed"); + expect(calls).toEqual(["one", "two result-1"]); + }); + + test("enforces the configured timeout for DAG workflows", async () => { + const runner: AgentRunner = { + async run(input) { + return new Promise((_resolve, reject) => { + input.signal?.addEventListener( + "abort", + () => reject(input.signal?.reason), + { once: true }, + ); + }); + }, + }; + const { service } = await setup(runner, { workflows: { timeoutMs: 100 } }); + const run = await service.startWorkflow({ + sessionID: "parent", + messageID: "timeout-message", + definition: { + kind: "dag", + name: "timeout", + steps: [{ id: "blocked", prompt: "never finishes" }], + }, + input: "", + }); + + expect(run.status).toBe("failed"); + expect(run.error).toContain("timed out after 100 ms"); + }); + + test("persists validation failures as durable failed runs", async () => { + const { store, service } = await setup({ + async run() { + throw new Error("must not run"); + }, + }); + const run = await service.startWorkflow({ + sessionID: "parent", + messageID: "invalid-message", + definition: { + kind: "dag", + name: "cycle", + steps: [ + { id: "one", needs: ["two"], prompt: "one" }, + { id: "two", needs: ["one"], prompt: "two" }, + ], + }, + input: "", + }); + + expect(run.status).toBe("failed"); + expect(run.error).toContain("dependency cycle"); + expect((await store.getRun(run.id))?.status).toBe("failed"); + }); +}); + +async function setup(runner: AgentRunner, options?: Record) { + const directory = await mkdtemp(join(tmpdir(), "opencode-run-service-")); + const store = new StateStore(join(directory, "state.sqlite")); + await store.initializeFleet(fleet()); + const service = new RunService(store, runner, parseOptions(options)); + cleanup.push(async () => { + await service.dispose(); + await store.close(); + }); + return { store, service }; +} + +function fleet(): Fleet { + return { + leadID: "lead", + members: [{ + id: "lead", + role: "lead", + model: { providerID: "test", modelID: "model" }, + enabled: true, + }], + }; +} + +async function waitFor(check: () => boolean | Promise) { + const started = Date.now(); + while (!(await check())) { + if (Date.now() - started > 2_000) throw new Error("Timed out waiting for state."); + await Bun.sleep(5); + } +} diff --git a/packages/opencode-multimodel/tests/plugin.test.ts b/packages/opencode-multimodel/tests/plugin.test.ts index 33d361c..b9d8ee5 100644 --- a/packages/opencode-multimodel/tests/plugin.test.ts +++ b/packages/opencode-multimodel/tests/plugin.test.ts @@ -14,6 +14,24 @@ test("publishes separate valid OpenCode server and TUI modules", () => { test("server plugin registers fleet, collaboration and workflow surfaces", async () => { const directory = `${process.env.TMPDIR ?? "/tmp"}/opencode-multimodel-${crypto.randomUUID()}`; + let serverRequests = 0; + const server = Bun.serve({ + port: 0, + fetch(request) { + serverRequests += 1; + const path = new URL(request.url).pathname; + if (request.method === "POST" && path === "/session") { + return Response.json({ id: "child" }); + } + if (request.method === "POST" && path === "/session/child/message") { + return Response.json({ parts: [{ type: "text", text: "answer" }] }); + } + if (request.method === "POST" && path === "/session/child/abort") { + return Response.json(true); + } + return new Response("not found", { status: 404 }); + }, + }); const fleet: Fleet = { leadID: "lead", members: [ @@ -26,22 +44,32 @@ test("server plugin registers fleet, collaboration and workflow surfaces", async ], }; const asked: string[] = []; + const legacyCalls: unknown[] = []; const plugin = await serverModule.server( { directory, client: { session: { - async create() { + async create(input: unknown) { + legacyCalls.push(input); return { data: { id: "child" } }; }, - async prompt() { + async prompt(input: unknown) { + legacyCalls.push(input); return { data: { parts: [{ type: "text", text: "answer" }] } }; }, - async abort() { + async abort(input: unknown) { + legacyCalls.push(input); return { data: true }; }, }, + provider: { + async list() { + return { data: { all: [] } }; + }, + }, }, + serverUrl: new URL(server.url), } as never, { statePath: `${directory}/state.json`, fleet }, ); @@ -57,9 +85,17 @@ test("server plugin registers fleet, collaboration and workflow surfaces", async "workflows", ]), ); + expect(config.command?.collab?.template).toContain( + "copy every character after the following whitespace into prompt", + ); + expect(config.command?.collab?.template).toContain( + "MUST NOT be empty", + ); + expect(config.command?.mode).toBeUndefined(); expect(Object.keys(plugin.tool ?? {})).toEqual([ "multimodel_fleet", "multimodel_collab", + "multimodel_run", "multimodel_workflow", ]); const output = await plugin.tool?.multimodel_fleet?.execute( @@ -67,6 +103,14 @@ test("server plugin registers fleet, collaboration and workflow surfaces", async {} as never, ); expect(output).toContain("Lead: lead"); + await plugin.tool?.multimodel_fleet?.execute( + { action: "set-lead", memberID: "lead" }, + { + async ask(input: { permission: string }) { + asked.push(input.permission); + }, + } as never, + ); const collaboration = await plugin.tool?.multimodel_collab?.execute( { prompt: "Question", mode: "lead" }, { @@ -79,11 +123,82 @@ test("server plugin registers fleet, collaboration and workflow surfaces", async } as never, ); expect(collaboration).toMatchObject({ - title: "lead: lead", + title: "lead: completed", output: "answer", }); - expect(asked).toEqual(["multimodel.collab"]); + expect(serverRequests).toBe(0); + expect(legacyCalls).toEqual([ + { + body: { + parentID: "parent", + title: "Fleet: lead (test/model)", + }, + }, + { + path: { id: "child" }, + body: { + model: { providerID: "test", modelID: "model" }, + agent: undefined, + system: expect.any(String), + tools: { + multimodel_collab: false, + multimodel_fleet: false, + multimodel_run: false, + multimodel_workflow: false, + codex_delegate: false, + codex_review: false, + codex_status: false, + codex_steer: false, + codex_cancel: false, + codex_probe: false, + }, + parts: [{ type: "text", text: expect.any(String) }], + }, + }, + ]); + expect(asked).toEqual(["multimodel.fleet", "multimodel.collab"]); + await plugin.dispose?.(); + server.stop(true); +}); + +test("defers provider discovery until the server is fully bootstrapped", async () => { + const directory = `${process.env.TMPDIR ?? "/tmp"}/opencode-multimodel-discovery-${crypto.randomUUID()}`; + let providerRequests = 0; + const server = Bun.serve({ + port: 0, + fetch(request) { + if (new URL(request.url).pathname !== "/provider") { + return new Response("not found", { status: 404 }); + } + providerRequests += 1; + return Response.json({ + connected: ["openai"], + default: { openai: "gpt-test" }, + all: [{ + id: "openai", + name: "OpenAI", + models: { "gpt-test": { id: "gpt-test", name: "GPT Test" } }, + }], + }); + }, + }); + const plugin = await serverModule.server( + { + directory, + serverUrl: new URL(server.url), + } as never, + ); + + expect(providerRequests).toBe(0); + const output = await plugin.tool?.multimodel_fleet?.execute( + { action: "list" }, + {} as never, + ); + expect(providerRequests).toBe(1); + expect(output).toContain("openai/gpt-test"); + await plugin.dispose?.(); + server.stop(true); }); test("TUI plugin registers slash commands and dedicated routes", async () => { @@ -160,7 +275,19 @@ test("TUI plugin registers slash commands and dedicated routes", async () => { "multimodel.collab", "multimodel.workflows", "multimodel.workflow", + "multimodel.runs", + "multimodel.run", + "multimodel.graph", + ]); + expect(slash).toEqual([ + "fleet", + "lead", + "collab", + "workflow", + "workflows", + "mode", + "runs", + "graph", ]); - expect(slash).toEqual(["fleet", "lead", "collab", "workflow", "workflows"]); await Promise.all(dispose.map((fn) => fn())); }); diff --git a/packages/opencode-multimodel/tests/script.test.ts b/packages/opencode-multimodel/tests/script.test.ts new file mode 100644 index 0000000..3fa70a2 --- /dev/null +++ b/packages/opencode-multimodel/tests/script.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import serverModule from "../src/server.ts"; +import { + runScriptWorkflow, + validateWorkflowScript, +} from "../src/script.ts"; +import { workflowSourceHash } from "../src/state.ts"; +import type { + AgentRunner, + Fleet, + ScriptWorkflowDefinition, +} from "../src/types.ts"; + +describe("confined script workflows", () => { + test("runs only the six-function expression surface", async () => { + const calls: string[] = []; + const runner: AgentRunner = { + async run(input) { + calls.push(input.prompt); + return { + memberID: input.member.id, + sessionID: `child-${calls.length}`, + model: input.member.model, + text: `answer:${input.prompt}`, + }; + }, + }; + const definition: ScriptWorkflowDefinition = { + kind: "script", + name: "research", + source: `export default async ({ args, agent, parallel, pipeline, phase, log }) => + pipeline([ + phase("research", parallel([ + agent({ prompt: "one", memberID: "worker" }), + agent("two") + ])), + log(args.input), + agent("three") + ])`, + }; + const updates: string[] = []; + const run = await runScriptWorkflow( + runner, + fleet(), + "parent", + definition, + "input value", + { onUpdate: (value) => void updates.push(value.status) }, + ); + + expect(calls.slice(0, 2).sort()).toEqual(["one", "two"]); + expect(calls[2]).toBe("three"); + expect(run.status).toBe("completed"); + expect(run.steps).toHaveLength(3); + expect(updates).toContain("running"); + expect(updates.at(-1)).toBe("completed"); + }); + + test("rejects host and evaluator escape attempts", () => { + for (const source of [ + 'import("node:fs")', + 'agent(process.env.SECRET)', + 'agent(Bun.file("secret"))', + 'agent(fetch("https://example.com"))', + 'agent(globalThis.constructor.constructor("return process")())', + 'agent(eval("1"))', + ]) { + expect(() => validateWorkflowScript(source)).toThrow(); + } + }); + + test("normalizes source before hashing", () => { + expect(workflowSourceHash("agent('one')\r\n")).toBe( + workflowSourceHash("agent('one')"), + ); + expect(workflowSourceHash("agent('two')")).not.toBe( + workflowSourceHash("agent('one')"), + ); + }); + + test("enforces timeout and call budget", async () => { + const runner: AgentRunner = { + async run(input) { + await new Promise((_resolve, reject) => { + input.signal?.addEventListener("abort", () => reject(input.signal?.reason), { + once: true, + }); + }); + throw new Error("unreachable"); + }, + }; + const run = await runScriptWorkflow( + runner, + fleet(), + "parent", + { kind: "script", name: "timeout", source: 'agent("wait")' }, + "", + { timeoutMs: 20 }, + ); + expect(run.status).toBe("cancelled"); + expect(run.error).toContain("exceeded 20 ms"); + + await expect(runScriptWorkflow( + { + async run(input) { + return { + memberID: input.member.id, + sessionID: "child", + model: input.member.model, + text: "done", + }; + }, + }, + fleet(), + "parent", + { + kind: "script", + name: "budget", + source: 'pipeline([agent("one"), agent("two")])', + }, + "", + { maxAgentCalls: 1 }, + )).resolves.toMatchObject({ status: "failed" }); + }); + + test("binds OpenCode permission to workflow name and source hash", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-script-permission-")); + const source = 'agent("review")'; + const asks: Array<{ patterns: string[] }> = []; + const plugin = await serverModule.server({ + directory, + client: { + session: { + async create() { + return { data: { id: "child" } }; + }, + async prompt() { + return { data: { parts: [{ type: "text", text: "done" }] } }; + }, + async abort() { + return { data: true }; + }, + }, + }, + } as never, { + databasePath: join(directory, "state.sqlite"), + fleet: fleet(), + workflows: { scripts: true }, + }); + const context = { + sessionID: "parent", + messageID: "message", + abort: new AbortController().signal, + metadata() {}, + async ask(input: { patterns: string[] }) { + asks.push(input); + }, + } as never; + await plugin.tool!.multimodel_workflow!.execute({ + action: "save", + definition: JSON.stringify({ kind: "script", name: "secure", source }), + }, context); + await plugin.tool!.multimodel_workflow!.execute({ + action: "run", + name: "secure", + input: "", + }, context); + + expect(asks.at(-1)?.patterns).toEqual([ + `secure:${workflowSourceHash(source)}`, + ]); + await plugin.dispose?.(); + }); +}); + +function fleet(): Fleet { + return { + leadID: "lead", + members: [ + { + id: "lead", + role: "lead", + model: { providerID: "test", modelID: "lead" }, + enabled: true, + }, + { + id: "worker", + role: "worker", + model: { providerID: "test", modelID: "worker" }, + enabled: true, + }, + ], + }; +} diff --git a/packages/opencode-multimodel/tests/state.test.ts b/packages/opencode-multimodel/tests/state.test.ts new file mode 100644 index 0000000..757573a --- /dev/null +++ b/packages/opencode-multimodel/tests/state.test.ts @@ -0,0 +1,182 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Database } from "bun:sqlite"; +import { StateStore } from "../src/state.ts"; +import type { CollaborationRun, Fleet } from "../src/types.ts"; + +const stores: StateStore[] = []; + +afterEach(async () => { + await Promise.allSettled(stores.splice(0).map((store) => store.close())); +}); + +describe("SQLite orchestration state", () => { + test("uses WAL, mode 0600, and accepts concurrent writers", async () => { + const path = await databasePath(); + const first = opened(path); + const second = opened(path); + await first.initializeFleet(fleet()); + await Promise.all( + Array.from({ length: 20 }, (_, index) => + (index % 2 ? first : second).upsertMember({ + id: `worker-${index}`, + role: "worker", + model: { providerID: "test", modelID: `model-${index}` }, + enabled: true, + isolation: "shared", + }) + ), + ); + + expect((await first.read()).fleet.members).toHaveLength(21); + expect((await stat(path)).mode & 0o777).toBe(0o600); + expect((await stat(`${path}-wal`)).mode & 0o777).toBe(0o600); + expect((await stat(`${path}-shm`)).mode & 0o777).toBe(0o600); + const database = new Database(path); + expect(database.query<{ journal_mode: string }, []>("PRAGMA journal_mode").get()?.journal_mode).toBe("wal"); + database.close(); + }); + + test("imports legacy JSON once without removing it", async () => { + const path = await databasePath(); + const legacyPath = path.replace(/\.sqlite$/, ".json"); + await Bun.write(legacyPath, JSON.stringify({ + version: 1, + fleet: fleet(), + workflows: [{ + name: "legacy", + steps: [{ id: "one", prompt: "Do it" }], + }], + runs: [], + })); + const store = opened(path, { legacyPath }); + const state = await store.read(); + + expect(state.workflows[0]).toMatchObject({ name: "legacy", kind: "dag" }); + expect(await Bun.file(legacyPath).exists()).toBe(true); + await Bun.write(legacyPath, "{}"); + const concurrent = opened(path, { legacyPath }); + expect((await concurrent.read()).workflows).toHaveLength(1); + }); + + test("deduplicates exact tool retries by session, message, and tool", async () => { + const store = opened(await databasePath()); + const first = run("one", "session", "message"); + const second = run("two", "session", "message"); + + expect((await store.createRun(first, "multimodel_collab")).id).toBe("one"); + expect((await store.createRun(second, "multimodel_collab")).id).toBe("one"); + expect(await store.listRuns()).toHaveLength(1); + expect((await store.createRun( + run("three", "session", "message"), + "multimodel_workflow", + )).id).toBe("three"); + }); + + test("marks expired in-flight leases interrupted and retains active runs", async () => { + const path = await databasePath(); + const store = opened(path, { retention: { runs: 2, events: 20 } }); + await store.createRun(run("active", "session-a", "message-a"), "tool"); + for (const id of ["done-1", "done-2", "done-3"]) { + const completed = run(id, `session-${id}`, `message-${id}`); + await store.createRun(completed, "tool"); + completed.status = "completed"; + completed.updatedAt += 1; + await store.saveRun(completed); + } + await store.close(); + stores.splice(stores.indexOf(store), 1); + const database = new Database(path); + database.query("UPDATE leases SET expires_at = 0 WHERE run_id = 'active'").run(); + database.close(); + + const recovered = opened(path, { retention: { runs: 2, events: 20 } }); + const runs = await recovered.listRuns(); + expect(runs.find((item) => item.id === "active")?.status).toBe("interrupted"); + expect(runs.filter((item) => item.status === "completed")).toHaveLength(2); + }); + + test("prevents a second process from stealing an unexpired run lease", async () => { + const path = await databasePath(); + const first = opened(path); + const second = opened(path); + await first.createRun(run("leased", "session", "message"), "tool"); + + expect(await second.claimLease("leased")).toBe(false); + const competing = (await second.getRun("leased"))!; + competing.status = "completed"; + await expect(second.saveRun(competing)).rejects.toThrow( + "leased by another plugin process", + ); + const database = new Database(path); + database.query("UPDATE leases SET expires_at = 0 WHERE run_id = 'leased'").run(); + database.close(); + expect(await second.claimLease("leased")).toBe(true); + }); + + test("caps the event ledger independently from active runs", async () => { + const store = opened(await databasePath(), { + retention: { runs: 2, events: 5 }, + }); + await store.createRun(run("active", "session", "message"), "tool"); + for (let index = 0; index < 12; index += 1) { + await store.appendEvent("active", `event.${index}`, { index }); + } + const state = await store.read(); + expect(state.events).toHaveLength(5); + expect(state.runs.find((item) => item.id === "active")).toBeDefined(); + }); +}); + +function opened( + path: string, + options?: ConstructorParameters[1], +) { + const store = new StateStore(path, options); + stores.push(store); + return store; +} + +async function databasePath() { + return join( + await mkdtemp(join(tmpdir(), "opencode-multimodel-state-")), + "state.sqlite", + ); +} + +function fleet(): Fleet { + return { + leadID: "lead", + members: [{ + id: "lead", + role: "lead", + model: { providerID: "test", modelID: "model" }, + enabled: true, + isolation: "shared", + }], + }; +} + +function run( + id: string, + sessionID: string, + messageID: string, +): CollaborationRun { + const now = Date.now(); + return { + id, + kind: "collaboration", + definition: "lead", + sessionID, + messageID, + input: "Question", + status: "pending", + mode: "lead", + participants: ["lead"], + steps: [], + createdAt: now, + updatedAt: now, + }; +} diff --git a/packages/opencode-multimodel/tests/workflow-files.test.ts b/packages/opencode-multimodel/tests/workflow-files.test.ts new file mode 100644 index 0000000..5cb9ba7 --- /dev/null +++ b/packages/opencode-multimodel/tests/workflow-files.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseOptions } from "../src/options.ts"; +import { StateStore } from "../src/state.ts"; +import { loadWorkflowDirectories } from "../src/workflow-files.ts"; + +const cleanup: Array<() => Promise> = []; + +afterEach(async () => { + await Promise.allSettled(cleanup.splice(0).map((close) => close())); +}); + +describe("configured workflow directories", () => { + test("loads DAG files and gates script files behind workflows.scripts", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-workflows-")); + const workflows = join(directory, ".opencode", "workflows"); + await mkdir(workflows, { recursive: true }); + await Bun.write(join(workflows, "review.json"), JSON.stringify({ + kind: "dag", + name: "review", + steps: [{ id: "review", prompt: "Review ${input}" }], + })); + await Bun.write( + join(workflows, "script.ts"), + 'export const meta = { name: "scripted" };\nexport default () => agent("lead", args("input"));', + ); + const store = new StateStore(join(directory, "state.sqlite")); + cleanup.push(async () => { + await store.close(); + await rm(directory, { recursive: true, force: true }); + }); + + await loadWorkflowDirectories( + store, + directory, + parseOptions(undefined).workflows, + ); + expect((await store.read()).workflows.map((item) => item.name)).toEqual([ + "review", + ]); + + await loadWorkflowDirectories( + store, + directory, + parseOptions({ workflows: { scripts: true } }).workflows, + ); + expect((await store.read()).workflows.map((item) => item.name)).toEqual([ + "review", + "scripted", + ]); + }); +});