From 3e1088acea0564d9f45bb9806b98cb8eee1a697a Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:23:36 +0000 Subject: [PATCH 01/49] docs(planning): scaffold v1.4.0 omp engine milestone Refs SPE-16069 --- .planning/PROJECT.md | 71 +++++++++++ .planning/REQUIREMENTS.md | 108 ++++++++++++++++ .planning/ROADMAP.md | 120 ++++++++++++++++++ .planning/STATE.md | 61 +++++++++ .planning/config.json | 78 ++++++++++++ .../phases/01-discovery-engine-port/.gitkeep | 0 .../phases/02-omp-engine-adapter/.gitkeep | 0 .planning/phases/03-prompt-uplift/.gitkeep | 0 .../04-substrate-shared-memory/.gitkeep | 0 .planning/phases/05-swarm-per-card/.gitkeep | 0 .../phases/06-omp-settings-terminal/.gitkeep | 0 .../07-integration-migration-removal/.gitkeep | 0 12 files changed, 438 insertions(+) create mode 100644 .planning/PROJECT.md create mode 100644 .planning/REQUIREMENTS.md create mode 100644 .planning/ROADMAP.md create mode 100644 .planning/STATE.md create mode 100644 .planning/config.json create mode 100644 .planning/phases/01-discovery-engine-port/.gitkeep create mode 100644 .planning/phases/02-omp-engine-adapter/.gitkeep create mode 100644 .planning/phases/03-prompt-uplift/.gitkeep create mode 100644 .planning/phases/04-substrate-shared-memory/.gitkeep create mode 100644 .planning/phases/05-swarm-per-card/.gitkeep create mode 100644 .planning/phases/06-omp-settings-terminal/.gitkeep create mode 100644 .planning/phases/07-integration-migration-removal/.gitkeep diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md new file mode 100644 index 0000000000..07adfcd884 --- /dev/null +++ b/.planning/PROJECT.md @@ -0,0 +1,71 @@ +# OpenFlow (autonogrammer) + +## What This Is + +OpenFlow (`packages/flow`) is a node-based visual builder for multi-agent workflows, shipped as a fork-owned package inside a vendored OpenCode monorepo. Each card on the canvas is one full agent session; cards run in pipeline, swarm or orchestration modes. Today the engine behind every card is `opencode serve`; milestone v1.4.0 replaces it with the Omp engine. + +## Core Value + +A user writes a card prompt and gets it executed by Omp with prompt uplift, shared project memory and agent-swarm execution, with status, output, result and every failure visible in the card UI. + +## Current Milestone: v1.4.0 Omp Engine + +**Goal:** Replace the OpenCode engine in `packages/flow` with the Omp engine, add prompt uplift (claude-ultrathink), shared memory (agent-substrate), per-card agent-swarm execution, and an Omp settings UI plus PTY terminal; then remove OpenCode coupling from fork-owned code. + +**Target features:** +- Engine port (`EngineApi`) with contract suite and `FakeEngine`; Omp adapter behind it (`omp --mode rpc`) +- Prompt uplift planner with persisted original + uplifted prompts +- Substrate-backed shared memory across all cards of a run +- Per-card agent-swarm execution via the agent-swarm omp extension +- Omp settings UI and in-app PTY terminal (bottom dock) +- Migration of persisted pipelines/runs/models and complete OpenCode removal from fork-owned paths + +Tracking: Linear nodes SPE-16062..SPE-16069 (n1..n8) under Notion task https://app.notion.com/p/3febc1a0c7ae819ea148eeb0486f8185?pvs=204 (graph `ut-mvf9vsaa-a99417c4`). Package version today: 1.3.0; target 1.4.0. + +## Requirements + +### Validated + +- ✓ Node-based canvas with pipeline / swarm / orchestration modes on the OpenCode engine — v1.0–v1.3.0 + +### Active + +See `.planning/REQUIREMENTS.md` (ENG, UPL, MEM, SWM, CFG, TRM, MIG, QA — 28 requirements). + +### Out of Scope + +- Modifying any vendored upstream package — FLOW.md hard constraint (fork merges stay clean) +- Scrubbing `opencode` strings in upstream README/docs/source/`sst.config.ts`/`infra` — intentional per FLOW.md +- An OpenCode fallback flag or silent fallback engine — OpenCode is removed outright after parity +- Multiple concurrent terminal sessions or remote terminal access — single local session only + +## Context + +- Repo rules: read `FLOW.md` and `AGENTS.md` at the repo root first; `packages/flow/README.md` documents endpoints, run instructions and file layout. +- Fork-owned surfaces: `packages/flow`, `.github`, the LICENSE attribution line, `FLOW.md`, and the root OpenFlow launchers (`openflow.sh`, `openflow.ps1`, `openflow.ts`). +- Current architecture: one card = one full `opencode serve` primary session (`src/server/engine.ts`: createSession → prompt → waitForIdle → transcript), nodes run in topological layers via a parallel pool. +- Branch `omp-engine`; default branch of the repo is `dev`. + +## Constraints + +- **Upstream**: no edits to vendored packages — keeps fork merges clean +- **Boundaries**: UI, engine and cards depend on the `EngineApi` port, never on omp/substrate/swarm internals +- **Security**: terminal bound to loopback with a per-launch token only +- **Process hygiene**: cancel must terminate the whole omp/swarm process tree, no orphans +- **Degradation**: every failure mode (omp missing/wrong version, uplift failing, substrate down, swarm partial failure, PTY exit) surfaces a visible, actionable message + +## Key Decisions + +| Decision | Rationale | Outcome | +|----------|-----------|---------| +| Remove OpenCode outright after parity (no flag, no silent fallback) | Two engines doubles surface and hides regressions; parity checklist gates removal | — Pending | +| Vendored upstream packages untouched (FLOW.md hard constraint) | Keeps fork merges clean; OpenCode removal applies only to `packages/flow` and root openflow launchers | — Pending | +| Uplift failure blocks the card with an explicit "Run with original prompt" action | No silent degradation; user stays in control of what is sent | — Pending | +| Memory: read per project, write per card; retention per Substrate semantics; inspect/clear as Substrate supports | Later cards see earlier results without cards overwriting each other's scope | — Pending | +| Terminal = global bottom dock, single session, loopback + per-launch token | Smallest safe surface for an in-app shell | — Pending | +| Legacy cards: model ids migrated to the Omp catalog, unknown ones flagged, never crashing | Existing pipelines must load | — Pending | +| Omp driven via `omp --mode rpc` per card session with a hand-rolled JSONL client | Direct control of stream/cancel/permission events; no SDK coupling | — Pending | +| Uplift = host-side planner call (to be confirmed by Phase 1 discovery) | Keeps uplift outside the card session so original/uplifted prompts are both persisted | — Pending | + +--- +*Last updated: 2026-10-19 after milestone v1.4.0 initialization* diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md new file mode 100644 index 0000000000..8d936f7db4 --- /dev/null +++ b/.planning/REQUIREMENTS.md @@ -0,0 +1,108 @@ +# Requirements: OpenFlow v1.4.0 Omp Engine + +**Defined:** 2026-10-19 +**Core Value:** A card prompt is uplifted, executed by Omp with swarm, and its status/output/result are visible in the card UI, with every failure surfaced and actionable. + +## v1.4.0 Requirements + +### Engine + +- [ ] **ENG-01**: `EngineApi` port types and a shared contract suite exist; UI, engine and cards depend on the port, not on omp/substrate/swarm internals. +- [ ] **ENG-02**: A `FakeEngine` implements the port and passes the contract suite, so UI and pipeline stages are testable with mocked boundaries. +- [ ] **ENG-03**: A host-side `omp --mode rpc` session manager and `/flow/api/engine` routes plus browser client stream card output; cancelling a run terminates the omp process tree with no orphans. +- [ ] **ENG-04**: The Omp adapter supports permissions, questions, usage reporting and transcript parity with the previous engine's card experience. +- [ ] **ENG-05**: When omp is missing or the wrong version, the card and settings show a visible, actionable message instead of failing silently. + +### Prompt Uplift + +- [ ] **UPL-01**: A card prompt is uplifted by a claude-ultrathink planner (including Jev decisions) before it is sent to omp. +- [ ] **UPL-02**: Both the original and the uplifted prompt are persisted and retrievable from the card UI. +- [ ] **UPL-03**: If uplift fails, the card is blocked with a visible message and an explicit "Run with original prompt" action; nothing is sent silently. +- [ ] **UPL-04**: Uplift is a host-side planner call whose boundary is mockable and whose placement is confirmed by Phase 1 discovery. + +### Shared Memory + +- [ ] **MEM-01**: Each run reads a Substrate brief before cards start, scoped project-read. +- [ ] **MEM-02**: Cards write governed memory (card-write scope) that a later card or later run in the same project can read. +- [ ] **MEM-03**: Memory content is redacted and guarded against prompt injection before being written or injected. +- [ ] **MEM-04**: The card UI shows a visible indicator when memory was used or unavailable; memory can be inspected and cleared as far as Substrate supports. + +### Swarm + +- [ ] **SWM-01**: Each card's omp session runs with the agent-swarm omp extension enabled. +- [ ] **SWM-02**: Card status reflects swarm progress, including partial failure with an actionable message. +- [ ] **SWM-03**: Cancelling a card kills the full swarm process tree, and failed swarm steps retry within a bounded budget. + +### Configuration + +- [ ] **CFG-01**: Omp settings are generated from omp's real options, with edit, save and reset. +- [ ] **CFG-02**: Invalid settings produce validation errors; the UI shows effective config plus omp availability and version. +- [ ] **CFG-03**: A saved settings change affects subsequent runs. + +### Terminal + +- [ ] **TRM-01**: A PTY terminal docks at the bottom of the app as a single global session bound to loopback with a per-launch token only. +- [ ] **TRM-02**: The user can run omp interactively, resize, copy, paste, scroll back and close the terminal. +- [ ] **TRM-03**: After the PTY exits the terminal shows a visible message and can be reopened or restarted. + +### Migration and Removal + +- [ ] **MIG-01**: Existing pipelines, runs and settings referencing opencode load without crashing; model ids are migrated to the Omp catalog and unknown ones are flagged. +- [ ] **MIG-02**: No OpenCode references remain in fork-owned code, config, dependencies, UI text or tests (`packages/flow`, root openflow launchers); vendored upstream packages are untouched, enforced by a `check:no-opencode` gate scoped to fork-owned paths. +- [ ] **MIG-03**: A parity checklist is satisfied before removal, and docs (README, FLOW.md, changelog) describe the Omp engine. + +### Verification + +- [ ] **QA-01**: Tests cover the adapter, pipeline stages with mocked boundaries, config validation and terminal lifecycle. +- [ ] **QA-02**: The terminal and settings UI are fully keyboard accessible. +- [ ] **QA-03**: The milestone audit passes against these requirements. + +## Out of Scope + +| Feature | Reason | +|---------|--------| +| OpenCode fallback flag / silent fallback | OpenCode is removed outright after parity | +| Changes to vendored upstream packages | FLOW.md hard constraint | +| Multiple terminal sessions / remote terminal | Single local session only | + +## Traceability + +| Requirement | Phase | Status | +|-------------|-------|--------| +| ENG-01 | Phase 1 | Pending | +| ENG-02 | Phase 1 | Pending | +| ENG-03 | Phase 2 | Pending | +| ENG-04 | Phase 2 | Pending | +| ENG-05 | Phase 2 | Pending | +| UPL-01 | Phase 3 | Pending | +| UPL-02 | Phase 3 | Pending | +| UPL-03 | Phase 3 | Pending | +| UPL-04 | Phase 3 | Pending | +| MEM-01 | Phase 4 | Pending | +| MEM-02 | Phase 4 | Pending | +| MEM-03 | Phase 4 | Pending | +| MEM-04 | Phase 4 | Pending | +| SWM-01 | Phase 5 | Pending | +| SWM-02 | Phase 5 | Pending | +| SWM-03 | Phase 5 | Pending | +| CFG-01 | Phase 6 | Pending | +| CFG-02 | Phase 6 | Pending | +| CFG-03 | Phase 6 | Pending | +| TRM-01 | Phase 6 | Pending | +| TRM-02 | Phase 6 | Pending | +| TRM-03 | Phase 6 | Pending | +| QA-02 | Phase 6 | Pending | +| MIG-01 | Phase 7 | Pending | +| MIG-02 | Phase 7 | Pending | +| MIG-03 | Phase 7 | Pending | +| QA-01 | Phase 7 | Pending | +| QA-03 | Phase 7 | Pending | + +**Coverage:** +- v1.4.0 requirements: 28 total +- Mapped to phases: 28 +- Unmapped: 0 + +--- +*Requirements defined: 2026-10-19* +*Last updated: 2026-10-19 after milestone v1.4.0 initialization* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md new file mode 100644 index 0000000000..fd6265b951 --- /dev/null +++ b/.planning/ROADMAP.md @@ -0,0 +1,120 @@ +# Roadmap: OpenFlow + +## Milestones + +- 🚧 **v1.4.0 Omp Engine** - Phases 1-7 (in progress) + +## Overview + +Milestone v1.4.0 "Omp Engine" swaps the OpenCode engine in `packages/flow` for Omp. Phase 1 grounds the work in evidence and introduces a port so everything downstream depends on `EngineApi`. Phases 2-5 build the Omp adapter, prompt uplift, Substrate memory and per-card swarm behind that port. Phase 6 adds Omp settings and the PTY terminal. Phase 7 wires everything, migrates persisted data, removes OpenCode from fork-owned paths and audits the milestone. + +## Phases + +**Phase Numbering:** +- Integer phases (1, 2, 3): Planned milestone work +- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED) + +- [ ] **Phase 1: Discovery and engine port** - Evidence-based CONTEXT; EngineApi port types, contract suite and FakeEngine +- [ ] **Phase 2: Omp engine adapter** - Host-side `omp --mode rpc` session manager, `/flow/api/engine` routes, browser client +- [ ] **Phase 3: Prompt uplift** - claude-ultrathink planner with persisted original and uplifted prompts +- [ ] **Phase 4: Substrate shared memory** - Brief read and governed memory write/read across cards of a run +- [ ] **Phase 5: Swarm per card** - Omp card sessions run with the agent-swarm omp extension +- [ ] **Phase 6: Omp settings and terminal** - Settings from omp's real options and a PTY terminal bottom dock +- [ ] **Phase 7: Integration, migration and OpenCode removal** - Wire hubs, migrate data, remove OpenCode, docs, gate, audit + +## Phase Details + +### Phase 1: Discovery and engine port +**Goal**: An evidence-based CONTEXT exists and UI, engine and cards depend on an `EngineApi` port (with contract suite and `FakeEngine`) rather than on omp/substrate/swarm internals. +**Depends on**: Nothing (first phase) +**Requirements**: ENG-01, ENG-02 +**Success Criteria** (what must be TRUE): + 1. CONTEXT documents the current engine surface, omp RPC protocol, uplift/substrate/swarm integration points, each backed by file or command evidence. + 2. `EngineApi` port types exist and the engine consumers (UI, pipeline scheduler, cards) import only the port. + 3. A contract suite runs against `FakeEngine` and passes; the same suite is reusable by the Omp adapter. + 4. The uplift host-side placement decision is confirmed or revised with evidence. +**Plans**: TBD + +### Phase 2: Omp engine adapter +**Goal**: Cards can run on a host-side `omp --mode rpc` session behind the port, with streaming, cancel, permissions, questions, usage and transcript parity. +**Depends on**: Phase 1 +**Requirements**: ENG-03, ENG-04, ENG-05 +**Success Criteria** (what must be TRUE): + 1. A card run streams omp output into the card UI through `/flow/api/engine` and the browser client. + 2. Cancelling a run terminates the omp process tree; no orphan processes remain. + 3. Permission and question requests round-trip to the user; usage and transcript match the previous experience. + 4. Missing omp or a wrong version yields a visible actionable message. + 5. The adapter passes the Phase 1 contract suite. +**Plans**: TBD + +### Phase 3: Prompt uplift +**Goal**: Every card prompt is uplifted by the claude-ultrathink planner, including Jev decisions, with both prompts persisted and inspectable. +**Depends on**: Phase 2 +**Requirements**: UPL-01, UPL-02, UPL-03, UPL-04 +**Success Criteria** (what must be TRUE): + 1. Running a card sends the uplifted prompt to omp, including Jev decisions before each card prompt. + 2. The original and uplifted prompts are both retrievable from the card UI after the run. + 3. When uplift fails the card is blocked with a visible message and an explicit "Run with original prompt" action. + 4. Uplift boundary is mockable in tests. +**Plans**: TBD + +### Phase 4: Substrate shared memory +**Goal**: Cards in a run share memory via agent-substrate with governed scopes, redaction and visible status. +**Depends on**: Phase 3 +**Requirements**: MEM-01, MEM-02, MEM-03, MEM-04 +**Success Criteria** (what must be TRUE): + 1. A run reads a Substrate brief with project-read scope before cards start. + 2. A second card run reads context written by an earlier card/run (card-write scope) and the UI shows memory was used. + 3. Written and injected memory is redacted and injection-guarded. + 4. When Substrate is down the UI shows memory unavailable with an actionable message; inspect/clear works as far as Substrate supports. +**Plans**: TBD + +### Phase 5: Swarm per card +**Goal**: Each card's omp session runs with the agent-swarm omp extension, with swarm progress reflected in card status. +**Depends on**: Phase 4 +**Requirements**: SWM-01, SWM-02, SWM-03 +**Success Criteria** (what must be TRUE): + 1. A card session starts with the agent-swarm extension loaded. + 2. Card status shows swarm progress and surfaces partial failure with an actionable message. + 3. Cancelling a card kills the swarm process tree with no orphans. + 4. Failed swarm steps retry within a bounded budget. +**Plans**: TBD + +### Phase 6: Omp settings and terminal +**Goal**: Users can configure Omp from the app and run omp interactively in a bottom-docked PTY terminal. +**Depends on**: Phase 2 +**Requirements**: CFG-01, CFG-02, CFG-03, TRM-01, TRM-02, TRM-03, QA-02 +**Success Criteria** (what must be TRUE): + 1. The settings UI is generated from omp's real options; valid edits save and reset, invalid ones show validation errors. + 2. Settings show effective config plus omp availability and version; a change affects subsequent runs. + 3. The terminal docks at the bottom, is loopback + per-launch-token only, runs omp interactively, and supports resize, copy, paste and scrollback. + 4. After PTY exit a visible message appears and the terminal can be restarted or reopened. + 5. Terminal and settings are fully keyboard accessible. +**Plans**: TBD + +### Phase 7: Integration, migration and OpenCode removal +**Goal**: Everything is wired end to end, persisted data migrates, OpenCode is removed from fork-owned code, and the milestone passes audit. +**Depends on**: Phase 5, Phase 6 +**Requirements**: MIG-01, MIG-02, MIG-03, QA-01, QA-03 +**Success Criteria** (what must be TRUE): + 1. Existing pipelines, runs and settings referencing opencode load without crashing; unknown model ids are flagged. + 2. The parity checklist is satisfied and docs describe the Omp engine. + 3. `check:no-opencode` passes on fork-owned paths (`packages/flow`, root openflow launchers); vendored upstream packages are unchanged. + 4. Tests cover adapter, pipeline stages with mocked boundaries, config validation and terminal lifecycle. + 5. The milestone audit passes. +**Plans**: TBD + +## Progress + +**Execution Order:** +Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 + +| Phase | Plans Complete | Status | Completed | +|-------|----------------|--------|-----------| +| 1. Discovery and engine port | 0/0 | Not started | - | +| 2. Omp engine adapter | 0/0 | Not started | - | +| 3. Prompt uplift | 0/0 | Not started | - | +| 4. Substrate shared memory | 0/0 | Not started | - | +| 5. Swarm per card | 0/0 | Not started | - | +| 6. Omp settings and terminal | 0/0 | Not started | - | +| 7. Integration, migration and OpenCode removal | 0/0 | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md new file mode 100644 index 0000000000..13e1626e45 --- /dev/null +++ b/.planning/STATE.md @@ -0,0 +1,61 @@ +--- +gsd_state_version: '1.0' +milestone: v1.4.0 +milestone_name: Omp Engine +status: planning +progress: + total_phases: 7 + completed_phases: 0 + total_plans: 0 + completed_plans: 0 + percent: 0 +--- + +# Project State + +## Project Reference + +See: .planning/PROJECT.md (updated 2026-10-19) + +**Core value:** A card prompt is uplifted, executed by Omp with swarm, and its status/output/result are visible in the card UI. +**Current focus:** Phase 1 — Discovery and engine port + +## Current Position + +Phase: 1 of 7 (Discovery and engine port) +Plan: 0 of 0 in current phase +Status: Not started +Last activity: 2026-10-19 — Milestone v1.4.0 Omp Engine scaffolded + +Progress: [░░░░░░░░░░] 0% + +## Performance Metrics + +**Velocity:** +- Total plans completed: 0 + +## Accumulated Context + +### Decisions + +Decisions are logged in PROJECT.md Key Decisions table. + +### Pending Todos + +None yet. + +### Blockers/Concerns + +None yet. + +## Deferred Items + +| Category | Item | Status | Deferred At | Milestone | +|----------|------|--------|-------------|-----------| +| *(none)* | | | | | + +## Session Continuity + +Last session: 2026-10-19 +Stopped at: Planning scaffold created; Phase 1 not started +Resume file: None diff --git a/.planning/config.json b/.planning/config.json new file mode 100644 index 0000000000..44037c31db --- /dev/null +++ b/.planning/config.json @@ -0,0 +1,78 @@ +{ + "model_profile": "inherit", + "commit_docs": true, + "parallelization": true, + "search_gitignored": false, + "brave_search": false, + "firecrawl": false, + "exa_search": false, + "tavily_search": false, + "ref_search": false, + "perplexity": false, + "jina": false, + "git": { + "branching_strategy": "none", + "create_tag": true, + "phase_branch_template": "gsd/phase-{phase}-{slug}", + "milestone_branch_template": "gsd/{milestone}-{slug}", + "quick_branch_template": null + }, + "workflow": { + "research": true, + "plan_check": true, + "verifier": true, + "nyquist_validation": true, + "auto_advance": true, + "node_repair": true, + "node_repair_budget": 2, + "ui_phase": true, + "ui_safety_gate": true, + "ai_integration_phase": true, + "api_coverage_gate": true, + "human_verify_mode": "end-of-phase", + "context_guard_mode": "warn", + "text_mode": false, + "compact_content": false, + "research_before_questions": false, + "discuss_mode": "discuss", + "skip_discuss": false, + "code_review": true, + "code_review_depth": "deep", + "code_review_command": null, + "pattern_mapper": true, + "plan_bounce": false, + "plan_bounce_script": null, + "plan_bounce_passes": 2, + "auto_prune_state": false, + "post_planning_gaps": true, + "security_enforcement": true, + "security_asvs_level": 1, + "security_block_on": "high", + "tdd_mode": true, + "ui_review": true + }, + "ship": { + "pr_body_sections": [] + }, + "hooks": { + "context_warnings": true + }, + "project_code": null, + "phase_naming": "sequential", + "agent_skills": {}, + "claude_md_path": "./.claude/CLAUDE.md", + "plan_review": { + "source_grounding": true, + "source_grounding_authority": "grep" + }, + "resolve_model_ids": "omit", + "intel": { + "enabled": true + }, + "graphify": { + "enabled": true, + "auto_update": true + }, + "mode": "yolo", + "granularity": "standard" +} diff --git a/.planning/phases/01-discovery-engine-port/.gitkeep b/.planning/phases/01-discovery-engine-port/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.planning/phases/02-omp-engine-adapter/.gitkeep b/.planning/phases/02-omp-engine-adapter/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.planning/phases/03-prompt-uplift/.gitkeep b/.planning/phases/03-prompt-uplift/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.planning/phases/04-substrate-shared-memory/.gitkeep b/.planning/phases/04-substrate-shared-memory/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.planning/phases/05-swarm-per-card/.gitkeep b/.planning/phases/05-swarm-per-card/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.planning/phases/06-omp-settings-terminal/.gitkeep b/.planning/phases/06-omp-settings-terminal/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.planning/phases/07-integration-migration-removal/.gitkeep b/.planning/phases/07-integration-migration-removal/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 From e7ee52bb9f34ce293c5b0ee50d0c37a7d7195934 Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:41:28 +0000 Subject: [PATCH 02/49] feat(flow): add omp engine wire contract Types and routes shared by the omp host, the browser client and the stage modules; omp binary resolver. Refs SPE-16063 --- packages/flow/lib/omp/binary.ts | 122 +++++++++++++ packages/flow/lib/omp/rpc-types.ts | 77 +++++++++ packages/flow/shared/clarification.ts | 15 ++ packages/flow/shared/engine.ts | 238 ++++++++++++++++++++++++++ packages/flow/shared/memory.ts | 140 +++++++++++++++ packages/flow/shared/omp.ts | 140 +++++++++++++++ packages/flow/shared/swarm.ts | 97 +++++++++++ packages/flow/shared/terminal.ts | 87 ++++++++++ packages/flow/shared/uplift.ts | 92 ++++++++++ packages/flow/tsconfig.json | 2 +- 10 files changed, 1009 insertions(+), 1 deletion(-) create mode 100644 packages/flow/lib/omp/binary.ts create mode 100644 packages/flow/lib/omp/rpc-types.ts create mode 100644 packages/flow/shared/clarification.ts create mode 100644 packages/flow/shared/engine.ts create mode 100644 packages/flow/shared/memory.ts create mode 100644 packages/flow/shared/omp.ts create mode 100644 packages/flow/shared/swarm.ts create mode 100644 packages/flow/shared/terminal.ts create mode 100644 packages/flow/shared/uplift.ts diff --git a/packages/flow/lib/omp/binary.ts b/packages/flow/lib/omp/binary.ts new file mode 100644 index 0000000000..8a1f38f4ce --- /dev/null +++ b/packages/flow/lib/omp/binary.ts @@ -0,0 +1,122 @@ +import { execFile } from "node:child_process" +import { accessSync, constants, realpathSync } from "node:fs" +import path from "node:path" +import { MIN_OMP_VERSION, type OmpProblem, type OmpSettings, type OmpStatus } from "../../shared/omp" + +/** + * Finding omp and proving it runs. + * + * Shared by the launch builder, the settings status, the model catalog and the + * terminal, so there is exactly one answer to "which omp, and is it new enough". + * Runs under both hosts — Bun (`server.ts`) and Node (the vite dev plugin) — so + * it uses `node:` APIs only. + */ + +const CACHE_MS = 30_000 +const VERSION_TIMEOUT_MS = 5_000 + +let cached: { key: string; at: number; status: OmpStatus } | undefined + +/** `omp/18.12.2`, `18.12.2` or anything containing a semver triple. */ +export function parseVersion(text: string): [number, number, number] | undefined { + const match = /(\d+)\.(\d+)\.(\d+)/.exec(text) + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined +} + +export function versionAtLeast(version: string, minimum: string): boolean { + const have = parseVersion(version) + const want = parseVersion(minimum) + if (!have || !want) return false + for (let index = 0; index < 3; index++) { + if (have[index] !== want[index]) return have[index]! > want[index]! + } + return true +} + +/** + * Absolute, symlink-resolved path of the omp binary: the one the settings name + * (it must be absolute and executable), else the first `omp` on PATH. + */ +export function resolveBinary(settings: Pick, env: NodeJS.ProcessEnv = process.env): string | undefined { + const names = process.platform === "win32" ? ["omp.exe", "omp.cmd", "omp.bat", "omp"] : ["omp"] + const candidates = settings.binary + ? [settings.binary] + : (env.PATH ?? "") + .split(path.delimiter) + .filter(Boolean) + .flatMap((directory) => names.map((name) => path.join(directory, name))) + + for (const candidate of candidates) { + if (!path.isAbsolute(candidate)) continue + try { + accessSync(candidate, constants.X_OK) + return realpathSync(candidate) + } catch { + // Not here; try the next candidate. + } + } + return undefined +} + +function readVersion(binary: string, env: NodeJS.ProcessEnv): Promise { + const { promise, resolve, reject } = Promise.withResolvers() + execFile(binary, ["--version"], { timeout: VERSION_TIMEOUT_MS, env, windowsHide: true }, (error, stdout) => { + if (error) return reject(error) + resolve(parseVersion(stdout)?.join(".")) + }) + return promise +} + +/** + * Whether omp is usable, as the status the settings dialog and every run + * preflight read. Cached for 30 seconds per (binary setting, profile, PATH): + * a version probe is a process spawn and the answer rarely changes. + */ +export async function checkOmp(settings: OmpSettings, env: NodeJS.ProcessEnv = process.env): Promise { + const key = `${settings.binary}|${settings.profile}|${env.PATH ?? ""}` + if (cached && cached.key === key && Date.now() - cached.at < CACHE_MS) return cached.status + + const problems: OmpProblem[] = [] + const binary = resolveBinary(settings, env) + let version: string | undefined + + if (!binary) { + problems.push({ + code: "missing", + message: settings.binary ? `omp was not found at ${settings.binary}.` : "omp is not on PATH.", + fix: "Install omp (bun add -g @oh-my-pi/pi-coding-agent) or set its binary path in Omp settings.", + }) + } else { + version = await readVersion(binary, env).catch(() => undefined) + if (!version) { + problems.push({ + code: "spawn", + message: `\`${binary} --version\` did not answer.`, + fix: "Run it in the terminal to see why, or point Omp settings at a working binary.", + }) + } else if (!versionAtLeast(version, MIN_OMP_VERSION)) { + problems.push({ + code: "version", + message: `omp ${version} is older than the ${MIN_OMP_VERSION} OpenFlow needs.`, + fix: "Update omp (`omp update`) and run again.", + }) + } + } + + const status: OmpStatus = { + available: problems.length === 0, + binary, + version, + minVersion: MIN_OMP_VERSION, + profile: settings.profile, + problems, + checkedAt: Date.now(), + } + cached = { key, at: Date.now(), status } + return status +} + +/** Forget the cached status — after the settings change which binary or profile is in use. */ +export function resetOmpCheck() { + cached = undefined +} diff --git a/packages/flow/lib/omp/rpc-types.ts b/packages/flow/lib/omp/rpc-types.ts new file mode 100644 index 0000000000..9bb71c50f8 --- /dev/null +++ b/packages/flow/lib/omp/rpc-types.ts @@ -0,0 +1,77 @@ +/** + * The seam between the Omp RPC core (`lib/omp/rpc.ts`, `launch.ts`) and the + * session layer (`lib/omp/session.ts`, `events.ts`, `routes.ts`). The session + * layer codes against `OmpProcess` and is tested with a fake one; the core + * implements it over a real `omp --mode rpc-ui` child. Types only. + * + * Protocol facts both sides rely on (verified against omp 18.12.2, see + * `.planning/phases/01-discovery-engine-port/discovery/04-omp-rpc-cli.md`): + * - JSONL on stdio, one object per line, a `ready` frame first. + * - `negotiate_protocol { protocolVersion: 2 }` makes oversized objects arrive as + * `rpc_chunk { chunkId, index, count, byteLength, data }` frames (base64 UTF-8 + * slices of one JSON object). The core reassembles them; the session layer never + * sees a chunk. + * - Commands carry an `id`; the response is `{ id, type: "response", command, + * success, data? | error, code? }`. `prompt` is acknowledged on admission and + * completes later with a `prompt_result` frame carrying the same `id`. + * - Closing stdin makes omp finish accepted work, dispose and exit 0. + */ + +/** Any frame that is not a command response: events, `extension_ui_request`, `host_tool_call`, `prompt_result`, `session_settled`, `notice`, ... */ +export type OmpFrame = { type: string; [key: string]: unknown } + +export type RpcErrorInfo = { + command: string + message: string + /** omp's machine code when it sent one (`session_busy`, `stale_cursor`, ...). */ + code?: string +} + +/** A command that came back `success: false`, timed out, or lost its process. Carries `rpc`. */ +export type OmpRpcError = Error & { rpc: RpcErrorInfo } + +/** What to run. Produced by `buildLaunch` (`lib/omp/launch.ts`); consumed by `spawnOmp`. */ +export type LaunchSpec = { + /** Absolute binary first, then flags. Never a shell string. */ + argv: string[] + /** Complete child environment (already filtered); nothing is inherited implicitly. */ + env: Record + cwd: string + /** Files to write before spawning (role prompt, settings overlay), absolute paths, written 0600. */ + files: Array<{ path: string; content: string }> +} + +export type OmpExit = { code: number | null; signal: string | null; stderrTail: string } + +export interface OmpProcess { + readonly pid: number | undefined + /** Resolves after the `ready` frame and protocol negotiation. Rejects with the stderr tail if the process exits first. */ + readonly ready: Promise<{ protocolVersion: number }> + /** Resolves when the process has exited, for any reason. Never rejects. */ + readonly exited: Promise + /** + * Send one command and resolve its response `data`. Rejects with an + * `OmpRpcError` on `success: false`, on timeout, on abort, or when the process + * exits first. Do not use for `prompt` completion: wait for its `prompt_result` + * frame through `onFrame`. + */ + command( + command: { type: string } & Record, + options?: { timeoutMs?: number; signal?: AbortSignal }, + ): Promise + /** Every frame that is not a response, in stdout order. Returns the unsubscribe function. */ + onFrame(listener: (frame: OmpFrame) => void): () => void + /** Answer an `extension_ui_request` or a `host_tool_call`; fire and forget (no response frame exists). */ + send(frame: Record): void + /** + * Graceful stop: close stdin, wait up to `graceMs` for exit, then SIGTERM the + * whole process group, then SIGKILL after `killMs`. Idempotent; resolves once + * the process is gone. + */ + close(options?: { graceMs?: number; killMs?: number }): Promise + /** Immediate SIGKILL of the whole process group. Idempotent. */ + kill(): void +} + +/** The factory the session layer receives; tests pass a fake. */ +export type SpawnOmp = (spec: LaunchSpec) => OmpProcess diff --git a/packages/flow/shared/clarification.ts b/packages/flow/shared/clarification.ts new file mode 100644 index 0000000000..cfadbeee30 --- /dev/null +++ b/packages/flow/shared/clarification.ts @@ -0,0 +1,15 @@ +/** + * A clarifying question the uplift planner drafted. The host passes them + * through from ultrathink's `SessionRecord.clarifications`; the UI may render + * them, the engine never answers them on the user's behalf. + */ +export type ClarificationInfo = { + id: string + header: string + question: string + /** Why the answer would change the work. */ + why?: string + blocking: boolean + options: Array<{ label: string; description?: string }> + default?: string +} diff --git a/packages/flow/shared/engine.ts b/packages/flow/shared/engine.ts new file mode 100644 index 0000000000..5d6d9e4703 --- /dev/null +++ b/packages/flow/shared/engine.ts @@ -0,0 +1,238 @@ +/** + * The engine wire: what the browser client (`src/server/client.ts`) and the + * Omp host (`lib/omp/*`) agree on. JSON over `/flow/api/engine/*` plus one SSE + * stream of `BusEvent`s. Types and route paths only; both sides import them and + * neither re-declares them. + * + * The browser run engine (`src/server/engine.ts`) talks to `EngineApi` and to + * nothing else — not to omp, Substrate, ultrathink or the swarm. Everything + * omp-shaped lives behind this wire, in the host. + * + * One Omp process (`omp --mode rpc-ui`) per card session; the host owns it, so + * a page reload does not end a session and `CreateSession.reuse` can re-attach. + */ + +import type { SwarmMode, SwarmStatus } from "./swarm" + +export const ENGINE_BASE = "/flow/api/engine" + +/* ------------------------------------------------------------------ catalog */ + +/** One pricing tier, USD per 1M tokens — the shape `src/server/usage.ts` prices from. */ +export type Price = { + input: number + output: number + cache: { read: number; write: number } + tier?: { type: "context"; size: number } +} + +export type ModelInfo = { + providerID: string + id: string + name?: string + reasoning?: boolean + contextWindow?: number + /** Input modalities, e.g. `["text", "image"]`. `accepts()` reads this. */ + capabilities?: { input: string[] } + /** Omp's per-model cost mapped to tiers. Absent or empty = the catalog quotes no price (unpriced, never "free"). */ + cost?: Price[] +} + +/* -------------------------------------------------- permissions and questions */ + +export type PermissionReply = "once" | "always" | "reject" + +export type QuestionOption = { label: string; description: string } + +export type QuestionInfo = { + question: string + header: string + options: QuestionOption[] + multiple?: boolean + custom?: boolean +} + +/* ------------------------------------------------------------------- events */ + +/** + * One event on the SSE stream (`engineRoutes.events`): `data: \n\n`. + * `: ping` comment lines keep the stream alive. There is no replay; the engine + * re-reads authoritative state (steps, transcript) after the fact. + * + * The host maps omp frames onto the names and fields the engine and + * `src/server/activity.ts` already read — do not rename them: + * - `session.created` sessionID, info.parentID (a subagent session started) + * - `session.next.step.started` sessionID, assistantMessageID, model{providerID,id}, agent + * - `session.next.step.ended` sessionID, assistantMessageID, tokens{input,output,reasoning,cache{read,write}} + * - `session.next.step.failed` sessionID, assistantMessageID, error + * - `session.next.text.started|delta|ended` sessionID, textID, delta|text + * - `session.next.reasoning.delta|ended` sessionID, reasoningID, delta|text + * - `session.next.tool.input.started` sessionID, callID, name + * - `session.next.tool.called` sessionID, callID, tool, input + * - `session.next.tool.progress` sessionID, callID, content[] + * - `session.next.tool.success|failed` sessionID, callID, content[]|result|error + * - `permission.v2.asked` sessionID, id, action, resources[] (omp approval prompt) + * - `question.v2.asked` sessionID, id, questions: QuestionInfo[] (omp `ask`) + * New: + * - `flow.swarm.status` sessionID, status: SwarmStatus + * - `flow.session.lost` sessionID, reason (the process died; the engine recreates the card's session) + * - `flow.notice` sessionID?, level: "info" | "error", message (omp `notice`, extension errors) + */ +export type BusEvent = { type: string; data?: Record } + +export type FlowSwarmEvent = { type: "flow.swarm.status"; data: { sessionID: string; status: SwarmStatus } } + +/* ----------------------------------------------------------------- sessions */ + +/** + * Everything the host needs to launch one card, in OpenFlow's own vocabulary. + * Replaces "merge agents into opencode.json and restart the engine": a changed + * card simply launches a fresh process with the new spec. + */ +export type CardSpec = { + /** Stable per-card key (`agentKey`): pipeline-role-node, sanitised. */ + key: string + role: string + /** Role instructions, appended to omp's own system prompt (never replacing it). */ + prompt?: string + /** Tool toggles in OpenFlow's vocabulary (`toolMap`): read grep glob edit bash webfetch websearch todowrite skill question. Absent = every tool. */ + tools?: Record + /** Allowlisted MCP server names; absent = every server omp has configured. */ + mcp?: string[] + /** Orchestrator cards get `openflow_dispatch` / `openflow_finish` as RPC host tools. */ + dispatch?: boolean +} + +export type CreateSession = { + /** The run this session belongs to; keys memory, uplift state and the swarm task store. */ + runId: string + nodeId: string + card: CardSpec + /** `provider/id`; absent = omp's configured default model. */ + model?: string + /** Working directory: the card's worktree, else the project. Fixed for the life of the session. */ + directory?: string + /** `auto` runs with approvals granted; `manual` surfaces them as `permission.v2.asked`. */ + permissions?: "auto" | "manual" + /** Absent = the settings default. */ + swarm?: SwarmMode + /** A session an earlier run left open (`RunOptions.sessions`): re-attach instead of creating. */ + reuse?: string +} + +export type SessionRow = { + id: string + title: string + agent?: string + model?: string + parent?: string + /** Epoch ms. */ + created: number + updated: number +} + +export type Turn = { role: "user" | "assistant"; text: string } + +/** Final assistant turn of a session: the joined text parts plus any error, already run through `describe`. */ +export type Transcript = { text: string; error?: string } + +export type ToolCall = { id: string; name: string; input: unknown } + +/** One settled assistant step. Structurally `StepUsage` (`graph/types`) plus what omp itself reported. */ +export type WireStep = { + /** Assistant message id — the dedupe key. Stable and unique per assistant turn. */ + messageID: string + /** `providerID/modelID` the step actually ran on. */ + model: string + tokens: { input: number; output: number; reasoning: number; cacheRead: number; cacheWrite: number } + /** USD omp reported for the step (`usage.cost.total`). Informational: the engine prices from the catalog. */ + reportedCost?: number +} + +export type PromptFile = { name: string; url: string; mime?: string } + +export type PromptRequest = { text: string; files?: PromptFile[] } + +export type WaitRequest = { timeoutMs?: number } + +/** + * Long-poll: resolves when omp reports the session settled (`prompt_result` plus + * `session_settled`, nothing queued or running in the background). + * `result` distinguishes a clean answer from an abort or a provider error; the + * engine must not treat `aborted` as success. + */ +export type WaitResult = { + status: "idle" | "timeout" | "missing" + result?: "completed" | "aborted" | "error" + error?: string +} + +export type HealthInfo = { + ok: boolean + omp: { available: boolean; version?: string } + sessions: { live: number } +} + +/** + * Every non-2xx answer. Provider failures keep the status in the text + * (`Provider request failed with HTTP 429 Too Many Requests …`) because the + * engine classifies rate limits and transient failures by regex on this text; + * a missing session says `session not found: `. + */ +export type EngineError = { error: string; code?: string } + +/* ------------------------------------------------------------------- routes */ + +export const engineRoutes = { + health: `${ENGINE_BASE}/health`, + models: `${ENGINE_BASE}/models`, + /** SSE. */ + events: `${ENGINE_BASE}/events`, + /** POST creates; GET lists `SessionRow[]` newest first (`?limit=`). */ + sessions: `${ENGINE_BASE}/sessions`, + /** GET: ids whose agent loop is executing right now. */ + active: `${ENGINE_BASE}/sessions/active`, + /** POST `{ runId }`: dispose every session of a run (run finished or abandoned). */ + releaseRun: `${ENGINE_BASE}/runs/release`, +} as const + +/** + * `/flow/api/engine/sessions/` with tails: + * `` DELETE dispose | `/prompt` POST PromptRequest | `/wait` POST WaitRequest -> WaitResult | + * `/interrupt` POST | `/transcript` GET ?limit -> Turn[] | `/final` GET -> Transcript | + * `/steps` GET -> WireStep[] | `/calls` GET ?limit -> ToolCall[] (newest first) | + * `/permission/` POST { reply: PermissionReply } | + * `/question/` POST { answers: string[][] } | `/question//reject` POST + */ +export function sessionRoute(id: string, tail = ""): string { + return `${ENGINE_BASE}/sessions/${encodeURIComponent(id)}${tail}` +} + +/* --------------------------------------------------------------------- port */ + +/** + * The port `src/server/engine.ts` depends on (`EngineDeps.api`). Implemented + * by `src/server/client.ts` over the routes above; implemented by test doubles + * in `engine.test.ts`. Optional members may be absent from a double. + */ +export interface EngineApi { + subscribe(onEvent: (event: BusEvent) => void, signal: AbortSignal): Promise + createSession(input: CreateSession): Promise<{ id: string }> + /** Resolves when the prompt is admitted, not when the turn ends. */ + prompt(sessionID: string, text: string, files?: PromptFile[]): Promise + /** Resolves on idle, silently on abort; throws on timeout, on a lost session, and on `aborted`/`error` results. */ + waitForIdle(sessionID: string, options?: { signal?: AbortSignal; interval?: number; timeout?: number }): Promise + transcript(sessionID: string): Promise + /** Never throws. */ + interrupt(sessionID: string): Promise + /** Dispose a session the engine abandons (a replaced critic session, a finished card). Best effort. */ + release?(sessionID: string): Promise + replyPermission(sessionID: string, requestID: string, reply: PermissionReply): Promise + replyQuestion(sessionID: string, requestID: string, answers: string[][]): Promise + rejectQuestion(sessionID: string, requestID: string): Promise + accepts(model: ModelInfo | undefined, mime: string): boolean + models(): Promise + describe(error: unknown, response?: Response): string + sessionSteps?(sessionID: string): Promise + sessionCalls?(sessionID: string, limit?: number): Promise +} diff --git a/packages/flow/shared/memory.ts b/packages/flow/shared/memory.ts new file mode 100644 index 0000000000..e16875fd11 --- /dev/null +++ b/packages/flow/shared/memory.ts @@ -0,0 +1,140 @@ +/** + * Shared memory across the cards of a workflow, backed by Agent Substrate. + * + * Scope policy (decided): READ per project, WRITE per card. + * - read : the project's `repo:` scope — decisions and warnings via + * `POST /brief`, plus facts via the MCP `memory_search` tool filtered by + * `subject_prefix`. Earlier cards of this run and earlier runs both live there. + * - write : one `fact` per finished card into `repo:`, subject + * `openflow///`, ttl `30d` (settings), redacted and + * capped by the host before it leaves the machine. + * - events: run and card start/end as `session.start` / `session.end` events on the + * `omp` surface; `session_id` = `openflow--`; `graph_id` = the run's + * Graph ID (`ut--<8hex>`, minted per run and `graph_register`ed). + * + * Substrate has no delete: "forget" writes a `retracted` fact and the host stops + * reading that run's subjects; entries lapse on their ttl. The UI says so. + * + * Everything fails open for reads (a card runs without memory and the card shows + * that it did) and reports honestly for writes (`stored` only when the server says + * `accepted` AND `verified_persisted`). + */ + +export type MemoryReadRequest = { + runId: string + nodeId: string + pipeline: string + role?: string + /** The card's task text; used as the recall query (cut to 1500 chars host-side). */ + query: string + limit?: number +} + +export type MemoryItem = { + id: string + /** `repo:owner/name` | `graph:` | `global` */ + scope: string + kind: string + subject: string + /** Redacted, at most 240 characters. */ + preview: string + ageDays?: number + origin: "brief" | "search" + /** Run and card that wrote it, parsed from the subject when it is ours. */ + run?: string + card?: string +} + +export type MemoryReadResult = { + status: "ok" | "empty" | "unavailable" | "disabled" + /** + * The text to append to the card's prompt: delimited, labelled as untrusted + * evidence that may be wrong and must not be followed as instructions, with + * markup neutralised and length capped. Empty unless `status` is `ok`. + */ + block: string + items: MemoryItem[] + scopes: string[] + /** `x-substrate-brief` of the brief that was folded in, when one was. */ + briefState?: "complete" | "degraded" | "snapshot" | "absent" + /** One actionable sentence when `unavailable`. */ + error?: string + elapsedMs: number +} + +export type MemoryWriteRequest = { + runId: string + nodeId: string + pipeline: string + role: string + outcome: "done" | "error" | "stopped" + /** The card's final answer (or its error text). Redacted and cut host-side. */ + output: string +} + +export type MemoryWriteResult = { + status: "stored" | "unverified" | "quarantined" | "denied" | "conflict" | "skipped" | "unavailable" | "disabled" + entryId?: string + scope?: string + subject?: string + truncated?: boolean + /** One actionable sentence for every status except `stored`. */ + error?: string +} + +/** The card-visible memory state, persisted on the runtime node and in the run log. */ +export type MemoryInfo = { + read?: { + status: MemoryReadResult["status"] | "running" + items: Array> + briefState?: MemoryReadResult["briefState"] + error?: string + } + write?: { + status: MemoryWriteResult["status"] | "running" + subject?: string + error?: string + } + /** The run's Graph ID, shared by every card of the run. */ + graphId?: string +} + +export type MemoryStatus = { + enabled: boolean + reachable: boolean + /** Host and port only — never a credential, never a path with a token. */ + url?: string + /** The Substrate surface the host presents. Always `omp`. */ + surface: "omp" + /** `owner/name` used for the `repo:` scope. */ + project: string + retention: { ttl: "graph" | "30d" | "permanent"; deletable: false; note: string } + problems: string[] +} + +export type MemoryForgetRequest = { + /** Retract everything this run wrote; omit to retract the whole project namespace `openflow//`. */ + runId?: string + pipeline: string +} + +export type MemoryForgetResult = { + status: "retracted" | "unavailable" | "disabled" + /** How many subjects were retracted and are now ignored on read. */ + retracted: number + error?: string +} + +export const MEMORY_ROUTES = { + status: "/flow/api/memory/status", + read: "/flow/api/memory/read", + write: "/flow/api/memory/write", + entries: "/flow/api/memory/entries", + forget: "/flow/api/memory/forget", +} as const + +/** Subject convention for everything OpenFlow writes. Lower-case, `/`-separated, at most 200 chars. */ +export function memorySubject(pipeline: string, run: string, card: string): string { + const clean = (value: string) => value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "x" + return `openflow/${clean(pipeline)}/${clean(run)}/${clean(card)}`.slice(0, 200) +} diff --git a/packages/flow/shared/omp.ts b/packages/flow/shared/omp.ts new file mode 100644 index 0000000000..4fbbdc1932 --- /dev/null +++ b/packages/flow/shared/omp.ts @@ -0,0 +1,140 @@ +/** + * Omp as a configurable engine: status, OpenFlow's own settings, and the real + * `omp config` surface. Host: `lib/omp-config`. Consumers: the Omp settings + * dialog, the Omp launch builder, the uplift / memory / swarm modules. + * + * Two layers, deliberately separate: + * 1. OpenFlow settings (`OmpSettings`) — what OpenFlow decides when it spawns + * omp: binary, profile, uplift / memory / swarm. Stored host-side in + * `/omp-settings.json`. No secrets; tokens only ever come + * from the host environment. + * 2. Omp's own configuration (`OmpConfigEntry`) — read and written through + * `omp config list|get|set|reset --json` against the configured profile, so + * omp itself validates it. The settings UI never invents an omp option. + */ + +import type { SwarmMode } from "./swarm" + +export const MIN_OMP_VERSION = "18.12.2" + +export type OmpSettings = { + /** Absolute path of the omp binary; empty resolves `omp` from PATH. */ + binary: string + /** + * Isolated omp profile the cards and the terminal run under (`--profile`): + * its own auth, config and sessions, so OpenFlow never reads or rewrites the + * user's default profile. Empty means omp's default profile. + */ + profile: string + /** Load the user's ambient omp extensions and plugins in cards. Off: `--no-extensions`. */ + loadUserExtensions: boolean + /** `--approval-mode` when a run uses the manual permission policy. */ + manualApproval: "always-ask" | "write" + /** `--max-time` for one card session, in minutes. */ + maxSessionMinutes: number + uplift: { + enabled: boolean + /** Wall clock for the planner process group. */ + timeoutMs: number + /** Let the planner draft clarifying questions (one more model call; cards run unattended). */ + hitl: boolean + /** Absolute path of the claude-ultrathink checkout or plugin; empty uses ULTRATHINK_ROOT, then omp's plugin directory. */ + root: string + } + memory: { + enabled: boolean + ttl: "graph" | "30d" | "permanent" + /** Most items folded into one card's prompt. */ + readLimit: number + /** `owner/name` override for the `repo:` scope; empty derives it from the project's git remote. */ + repo: string + } + swarm: { + mode: SwarmMode + /** Absolute path of the agent-swarm checkout; empty uses SWARM_ROOT / ULTRATHINK_SWARM_ROOT. */ + root: string + } + substrate: { + /** Empty uses SUBSTRATE_URL. */ + url: string + } +} + +export const DEFAULT_OMP_SETTINGS: OmpSettings = { + binary: "", + profile: "openflow", + loadUserExtensions: false, + manualApproval: "write", + maxSessionMinutes: 60, + uplift: { enabled: true, timeoutMs: 240_000, hitl: false, root: "" }, + memory: { enabled: true, ttl: "30d", readLimit: 8, repo: "" }, + swarm: { mode: "auto", root: "" }, + substrate: { url: "" }, +} + +/** Deep partial: the shape of a settings patch. */ +export type OmpSettingsPatch = { + [K in keyof OmpSettings]?: OmpSettings[K] extends object ? Partial : OmpSettings[K] +} + +export type ValidationError = { + /** Dotted path into the settings object, e.g. `uplift.timeoutMs`. */ + path: string + message: string +} + +export type OmpProblem = { + code: "missing" | "version" | "spawn" | "profile" | "no-credentials" | "no-models" + message: string + fix?: string +} + +export type OmpStatus = { + /** True when the binary resolved, ran `--version`, and satisfied `MIN_OMP_VERSION`. */ + available: boolean + /** Absolute resolved binary. */ + binary?: string + /** e.g. `18.12.2`. */ + version?: string + minVersion: string + profile: string + /** The profile's config directory (`omp config path`). */ + configDir?: string + providers?: { authenticated: number; total: number } + problems: OmpProblem[] + checkedAt: number +} + +export type OmpConfigType = "boolean" | "number" | "enum" | "string" | "array" | "record" + +export type OmpConfigEntry = { + /** Dotted omp setting key, e.g. `tools.approvalMode`. */ + key: string + type: OmpConfigType + description: string + value: unknown + /** Allowed values for `enum`, when omp's text listing exposes them. */ + options?: string[] + /** omp's own category heading. */ + group?: string + /** omp redacted the value; show it as set, never reveal it. */ + redacted?: boolean + /** False for types the UI does not edit as a scalar (array, record): shown read-only with a hint to use the terminal. */ + editable: boolean +} + +export type OmpProviderRow = { + id: string + name: string + available: boolean + authenticated: boolean +} + +export const OMP_ROUTES = { + status: "/flow/api/omp/status", + settings: "/flow/api/omp/settings", + settingsReset: "/flow/api/omp/settings/reset", + /** GET lists entries; PUT `/` sets `{ value }`; POST `//reset` resets. */ + config: "/flow/api/omp/config", + providers: "/flow/api/omp/providers", +} as const diff --git a/packages/flow/shared/swarm.ts b/packages/flow/shared/swarm.ts new file mode 100644 index 0000000000..7fcadec456 --- /dev/null +++ b/packages/flow/shared/swarm.ts @@ -0,0 +1,97 @@ +/** + * Agent Swarm under the hood of a card — the types the swarm module + * (`lib/swarm`), the Omp launch builder (`lib/omp/launch.ts`), the engine wire + * and the UI agree on. Types and constants only; nothing here touches disk. + * + * A card's Omp session loads agent-swarm's Omp extension (`-e /omp`). + * Whether the model then delegates to the swarm is the extension's own + * decision (HOOK-01 classifies SDLC-shaped prompts and adds a hint) unless the + * mode is `always`. + */ + +/** + * - `off`: the extension is not loaded; no hook, no hint, no signing need. + * - `auto`: the extension is loaded and its own classifier decides. + * - `always`: loaded, and the card's system prompt tells it to hand the work to + * `a01-orchestrator` through the `task` tool. + * + * Never implemented through the extension's `/swarm` command: that command + * writes `.claude/swarm-state.json` and appends to `.planning/STATE.md` in the + * session's cwd, which would pollute the user's repository. + */ +export type SwarmMode = "off" | "auto" | "always" + +export const SWARM_MODES: readonly SwarmMode[] = ["off", "auto", "always"] + +export type SwarmProblemCode = + | "no-root" // no agent-swarm checkout configured or discoverable + | "bad-root" // configured root has no omp/package.json or scripts/orch_plan.py + | "no-python" // python3 missing + | "no-crypto" // python cannot import `cryptography` (Ed25519 signing) + | "no-signing-key" // neither SWARM_ED25519_KEY(_FILE) nor SWARM_SIGNING_KEY is set (value-free check) + | "bad-state-dir" + +export type SwarmProblem = { + code: SwarmProblemCode + message: string + /** What the user can do about it, in one sentence. */ + fix?: string +} + +export type SwarmPreflight = { + /** True when a card can run with `mode` as asked. `off` is always ok. */ + ok: boolean + /** The mode that will actually be used: `auto`/`always` degrade to `off` when not ok. */ + effective: SwarmMode + requested: SwarmMode + problems: SwarmProblem[] + /** Absolute path of the agent-swarm checkout in use; absent when there is none. */ + root?: string +} + +/** + * What the swarm module contributes to one card's Omp launch. The launch + * builder appends `args` to argv, merges `env` over the filtered host env, and + * deep-merges `overlay` into the per-card `--config` overlay it writes. + * `env` never carries a signing key value into argv or a file: the host env + * passthrough is the only way a key reaches the child. + */ +export type SwarmLaunch = { + effective: SwarmMode + /** e.g. ["-e", "/omp"]. Empty when `effective` is `off`. */ + args: string[] + /** SWARM_DIR (absolute, outside the worktree), SWARM_ROOT when needed, ... Never SWARM_AGENT / SWARM_TASK_ID / SWARM_CHILD. */ + env: Record + /** Settings overlay: `task.maxRecursionDepth: 2`, task isolation off. */ + overlay: Record + /** Appended to the card's system prompt (mode `always`); empty otherwise. */ + systemPrompt: string + /** Absolute task-store directory for this card; the progress reader looks here. */ + stateDir: string + preflight: SwarmPreflight +} + +export type SwarmAgentState = { + /** Agent slug, e.g. `a05-backend`. */ + agent: string + state: "queued" | "running" | "completed" | "failed" | "cancelled" + /** One line: the last tool or phase, when known. */ + activity?: string + /** Epoch ms of the last change. */ + at: number +} + +/** The card-visible swarm state. Emitted on the engine stream as `flow.swarm.status`. */ +export type SwarmStatus = { + mode: SwarmMode + /** True once any delegation (a `task` call to a swarm agent) was seen this session. */ + active: boolean + correlationId?: string + counts: { queued: number; running: number; completed: number; failed: number; cancelled: number } + agents: SwarmAgentState[] + /** Set when the swarm degraded (preflight failed, a lane failed). Always actionable. */ + error?: string + updatedAt: number +} + +export const EMPTY_SWARM_COUNTS: SwarmStatus["counts"] = { queued: 0, running: 0, completed: 0, failed: 0, cancelled: 0 } diff --git a/packages/flow/shared/terminal.ts b/packages/flow/shared/terminal.ts new file mode 100644 index 0000000000..436e6592bb --- /dev/null +++ b/packages/flow/shared/terminal.ts @@ -0,0 +1,87 @@ +/** + * The in-app terminal: a PTY the host spawns and the browser drives over a + * WebSocket. Host: `lib/terminal`. Client: `src/ui/terminal-dock.tsx`. + * + * Loopback only, and a PTY is code execution as the user, so every route is + * gated three ways: the request must carry `x-openflow-pty: 1` (a custom header + * forces a CORS preflight this host never answers), its Origin must equal the + * loopback Host it was sent to, and the WebSocket is opened with a one-shot + * ticket that expires. With `FLOW_ALLOW_REMOTE=1` the terminal is off. + * + * The browser never sends argv, cwd or env — only a `kind`. + * + * WebSocket framing (extends the upstream PTY protocol, which has no resize): + * server -> client text frame raw terminal output + * binary 0x00 + JSON `PtyServerControl` + * client -> server text frame keystrokes (<= `inputFrameBytes`) + * binary 0x00 + JSON `PtyClientControl` + */ + +export type PtyKind = "omp" | "shell" + +export const PTY_HEADER = "x-openflow-pty" + +export type PtyCreate = { kind: PtyKind; cols: number; rows: number } + +/** Attach to a live or exited session by `id`, or create one. Exactly one of the two. */ +export type PtyTicketRequest = { id: string; create?: undefined } | { id?: undefined; create: PtyCreate } + +export type PtyTicket = { + id: string + ticket: string + kind: PtyKind + /** Epoch ms. */ + expiresAt: number + /** Path to open: `/flow/pty//ws`; the client appends `?ticket=…&cursor=…`. */ + wsPath: string +} + +export type PtyInfo = { + id: string + kind: PtyKind + pid?: number + state: "running" | "exited" + exit?: { code: number | null; signal: string | null } + cols: number + rows: number + createdAt: number +} + +export type PtyServerControl = { cursor: number } | { exit: { code: number | null; signal: string | null } } +export type PtyClientControl = { t: "resize"; cols: number; rows: number } + +/** First byte of a binary control frame. */ +export const PTY_CONTROL = 0x00 + +export const PTY_CLOSE = { normal: 1000, gone: 4404, auth: 4403 } as const + +export const PTY_LIMITS = { + maxSessions: 4, + /** Server-side scrollback ring, replayed on reconnect from a cursor. */ + ringBytes: 2 * 1024 * 1024, + inputFrameBytes: 64 * 1024, + minCols: 20, + maxCols: 500, + minRows: 5, + maxRows: 200, + ticketTtlMs: 60_000, + /** A running session nobody is attached to is killed after this long. */ + idleReapMs: 10 * 60_000, + /** Exited sessions kept so the dock can show "exited 1 — Restart". */ + exitedKept: 5, +} as const + +export const PTY_ROUTES = { + ticket: "/flow/api/pty/ticket", + list: "/flow/api/pty", + /** DELETE `/flow/api/pty/` kills and forgets a session. */ + session: (id: string) => `/flow/api/pty/${encodeURIComponent(id)}`, + ws: (id: string) => `/flow/pty/${encodeURIComponent(id)}/ws`, +} as const + +export function clampSize(cols: number, rows: number): { cols: number; rows: number } { + return { + cols: Number.isFinite(cols) ? Math.min(PTY_LIMITS.maxCols, Math.max(PTY_LIMITS.minCols, Math.trunc(cols))) : PTY_LIMITS.minCols, + rows: Number.isFinite(rows) ? Math.min(PTY_LIMITS.maxRows, Math.max(PTY_LIMITS.minRows, Math.trunc(rows))) : PTY_LIMITS.minRows, + } +} diff --git a/packages/flow/shared/uplift.ts b/packages/flow/shared/uplift.ts new file mode 100644 index 0000000000..d68d643a94 --- /dev/null +++ b/packages/flow/shared/uplift.ts @@ -0,0 +1,92 @@ +/** + * Prompt Uplift (claude-ultrathink) as the engine and the UI see it. + * + * The host (`lib/uplift`) runs ultrathink's headless planner — `bin/run-bun + * hooks/engine.ts`, JSON on stdin and stdout — with `host: "prime-agent"` (the + * `omp` host is `native-unavailable` through that entry) and the Claude CLI as + * the engine. Jev decisions run inside the planner whenever a key is present. + * + * Contract with the engine (`src/server/engine.ts`), in order: + * 1. a card turn of kind `task` calls `deps.uplift` with the card's own task + * text; protocol turns (verdict, force-final, dispatch result, reassign) + * are never uplifted; + * 2. `ok` — the engine sends `uplifted` instead of the task text; + * `skipped` — the planner declined on purpose (Jev judged it not new work, + * uplift is off, the text is trivial): send the original, note why; + * `failed` — nothing is sent. The engine blocks the card and asks the user: + * run with the original prompt, retry uplift, or cancel the card. + * 3. original and uplifted text are both kept on the card and in the run log. + */ + +import type { ClarificationInfo } from "./clarification" + +export type UpliftStatus = "ok" | "skipped" | "failed" + +export type UpliftRequest = { + runId: string + nodeId: string + /** Role of the card, for the log line only. */ + role?: string + /** The card's own task text. Never the upstream outputs, attachments or briefing boilerplate. */ + original: string + /** Project directory; the planner reads the repo for context from here. */ + cwd?: string + /** Wall clock for the whole planner process group. Default from settings. */ + timeoutMs?: number +} + +export type UpliftDecision = { + /** Jev decision point: `plan` | `blocking` | `knowledge` | ... */ + point: string + /** Probability the decision model returned, 0..1. */ + p: number + /** What the planner did with it. */ + action: string +} + +export type UpliftResult = { + status: UpliftStatus + original: string + /** The spec XML (+ the Graph-of-Thought guidance line) to send in place of `original`. Present when `ok`. */ + uplifted?: string + /** + * Why: for `skipped` the planner's reason (`jev-skip`, `empty`, `disabled`, + * `passthrough`, ...); for `failed` one actionable sentence (timeout, the + * `claude` CLI missing or logged out, planner crashed, fallback spec). + */ + reason?: string + /** `fallback` means the planner could not reach its engine and wrote a conservative spec: treated as `failed`. */ + source?: "llm" | "fallback" + graphId?: string + /** Absolute path of the saved spec XML. */ + specPath?: string + nodes?: number + waves?: number + /** Parts of the plan that fell back (`fill:n3`, ...). Non-empty with `ok` means a partial plan. */ + degraded?: string[] + decisions?: UpliftDecision[] + clarifications?: ClarificationInfo[] + engine?: string + elapsedMs: number +} + +/** The card-visible state, persisted on the runtime node and in the run log. */ +export type UpliftInfo = { + status: "running" | UpliftStatus | "declined" + original: string + uplifted?: string + reason?: string + source?: "llm" | "fallback" + graphId?: string + specPath?: string + nodes?: number + degraded?: string[] + decisions?: UpliftDecision[] + elapsedMs?: number + /** The user chose to run the original after a failure (the only way `failed` proceeds). */ + declined?: boolean +} + +export const UPLIFT_ROUTE = "/flow/api/uplift" +/** Uplifted text carries this root tag family; never uplift text that already starts with one. */ +export const UPLIFTED_ROOT_TAGS = ["BUILD_PROMPT", "FIX_PROMPT", "RESEARCH_PROMPT", "CHANGE_PROMPT", "UPLIFTED_PROMPT"] as const diff --git a/packages/flow/tsconfig.json b/packages/flow/tsconfig.json index 39ef925146..0cf196abac 100644 --- a/packages/flow/tsconfig.json +++ b/packages/flow/tsconfig.json @@ -18,6 +18,6 @@ "@/*": ["./src/*"] } }, - "include": ["src", "lib", "vite", "vite.config.ts", "server.ts"], + "include": ["src", "lib", "shared", "vite", "vite.config.ts", "server.ts"], "exclude": ["dist"] } From c3445cab697c4046b19addaa4c20b709d1f66157 Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:41:28 +0000 Subject: [PATCH 03/49] docs(planning): record phase 1 discovery reports Refs SPE-16062 --- .../discovery/01-engine-contract.md | 184 ++++++++++++++++++ .../discovery/02-host-store.md | 72 +++++++ .../discovery/03-ui-coupling.md | 83 ++++++++ .../discovery/04-omp-rpc-cli.md | 92 +++++++++ .../discovery/05-omp-extensions-mcp.md | 147 ++++++++++++++ .../discovery/06-ultrathink-planner.md | 156 +++++++++++++++ .../discovery/07-substrate-api.md | 130 +++++++++++++ .../discovery/08-swarm-omp-ext.md | 140 +++++++++++++ .../discovery/09-terminal-pty.md | 92 +++++++++ 9 files changed, 1096 insertions(+) create mode 100644 .planning/phases/01-discovery-engine-port/discovery/01-engine-contract.md create mode 100644 .planning/phases/01-discovery-engine-port/discovery/02-host-store.md create mode 100644 .planning/phases/01-discovery-engine-port/discovery/03-ui-coupling.md create mode 100644 .planning/phases/01-discovery-engine-port/discovery/04-omp-rpc-cli.md create mode 100644 .planning/phases/01-discovery-engine-port/discovery/05-omp-extensions-mcp.md create mode 100644 .planning/phases/01-discovery-engine-port/discovery/06-ultrathink-planner.md create mode 100644 .planning/phases/01-discovery-engine-port/discovery/07-substrate-api.md create mode 100644 .planning/phases/01-discovery-engine-port/discovery/08-swarm-omp-ext.md create mode 100644 .planning/phases/01-discovery-engine-port/discovery/09-terminal-pty.md diff --git a/.planning/phases/01-discovery-engine-port/discovery/01-engine-contract.md b/.planning/phases/01-discovery-engine-port/discovery/01-engine-contract.md new file mode 100644 index 0000000000..0c151b128c --- /dev/null +++ b/.planning/phases/01-discovery-engine-port/discovery/01-engine-contract.md @@ -0,0 +1,184 @@ +All paths below are relative to packages/flow/src. This is a read-only study. Nothing was run, and the rest of the 2265-line engine.ts (about 1200–1760 and 1800–1940, the orchestrate/gauntlet bodies) was skimmed rather than read. + +## 1. Existing contract, as engine.ts consumes it + +`EngineDeps.api` is a `Pick` of client.ts at server/engine.ts:254-291. + +| member | input → output as consumed | semantics the engine relies on | +|---|---|---| +| `subscribe` | `(cb:(e:BusEvent)=>void, signal)→Promise` | Resolves on stream end or abort; engine does `.catch(()=>undefined)` (engine.ts:687-). Best-effort; usage is re-read afterwards. | +| `createSession` | `{agent?,model?,directory?}→{id}` | `agent` is the generated agent key and `directory` is the per-card worktree (engine.ts:907-914). The directory is fixed for the life of the session. | +| `prompt` | `(sessionID,text,files:Attachment[])→any` | Resolves when the prompt is admitted, not when the turn ends (engine.ts:955). | +| `waitForIdle` | `(sessionID,{signal,timeout})→void` | Returns silently on abort. Throws "timed out waiting for the session to finish" or "the session never started executing…" (client.ts:318-345). Idle is derived from the active-session set plus a finished assistant message (`finish` or `error`). A 20s admission grace applies. | +| `transcript` | `(sessionID)→{text:string,error?:string}` | `error` is already run through `describe`. A non-empty `error` is thrown as the node failure (engine.ts:963). | +| `interrupt` | `(sessionID)→void` | Never throws; called from `stop()` for every id in `active`. | +| `replyPermission` | `(sid,reqID,"once"\|"always"\|"reject")` | Throw → notice "permission reply failed". | +| `replyQuestion` | `(sid,reqID,answers:string[][])` | Positional, one array of chosen labels per question. | +| `rejectQuestion` | `(sid,reqID)` | Used when unanswered or after `questionTimeout`. | +| `accepts` | `(model:ModelInfo\|undefined, mime)→boolean` | Sync. Reads `model.capabilities.input`. Also called with `"image/png"` for `imageBlindNote` (engine.ts:950). | +| `models` | `()→{providerID,id,capabilities?.input,cost?:Price[]}[]` | Keyed `${providerID}/${id}` into `catalog` (engine.ts:1979). Used for unknown-model rejection, one `catalogRetry` re-read, pricing, and `accepts`. | +| `agents` | `()→{id}[]` | `unknownAgents` (engine.ts:2241) compares ids to `node.agent.name`. | +| `agentsUnscoped?` | same | Optional. Used only when `agents()` is empty. | +| `describe` | `(unknown)→string` | Single source of error text. See the regex classifiers below. | +| `sessionSteps?` | `(sid)→StepUsage[]` | Authoritative usage, merged by `messageID` in `reconcile` (engine.ts:583). It runs in `finally`, so it runs on failure and stop too. On throw the engine notices "could not read usage". | +| `sessionCalls?` | `(sid,limit=30)→ToolCall[]` newest first | Only used by `decide()` (engine.ts:1852), and gated by `toolChannel` = `MCP_REACHES_SESSIONS` = false (graph/dispatch.ts:60). It is dead code today. | + +Error classifiers are regexes on the `describe()` text (engine.ts:1142-1191): +- **`rateLimited`**: `/\b429\b|rate.?limit|too many requests/i`. +- **`transient`**: not rate-limited, no `\b4(0-2)\d\b`, and matches `5xx`, bad gateway, service unavailable, gateway time-out, internal server error, `econnreset`, `econnrefused`, `etimedout`, `enotfound`, `eai_again`, `epipe`, `socket hang up`, `fetch failed`, `network error`, `connection closed/reset/refused/error`, or `premature close`. +- **`sessionLost`**: `/session (not found|does not exist|expired|closed|invalid)|unknown session|no such session/i`. +- **Retry budgets**: `RATE_LIMIT_RETRIES` = `TRANSIENT_RETRIES` = 3, counted separately. Wait is `rateLimitBackoff*2^attempt`, and `rateLimitBackoff=0` disables retries. +- **Session loss** drops the card's session and recreates it, charged on the transport budget. +- **`describe` fallback**: `unknown error`; with a Response it appends `(HTTP n statusText)`. + +## 2. Bus events (server/engine.ts:687-745, server/activity.ts:92-240) + +All events are `{type, data?}`; events without `data.sessionID` are ignored. + +| type | fields read | effect | +|---|---|---| +| `session.created` | `sessionID`, `info.parentID` | activity only: child session → node, depth+1, `callID` = last `task` call | +| `session.next.step.started` | `assistantMessageID`, `model{providerID,id}`, `agent` | engine: `stepModel[msgID]`, `patch{status:"running",activity:"thinking"}`; activity: `step:` row titled "agent · provider/model" | +| `session.next.step.ended` | `assistantMessageID`, `tokens{input,output,reasoning,cache{read,write}}` | usage map keyed by `messageID`, then `reprice()` → `patch{usage}` and `log.usage`. Skipped if `tokens` is missing. | +| `session.next.step.failed` | `assistantMessageID`, `error` | `patch{activity:"failed"}`; error note row | +| `session.next.text.started`/`.delta` | `textID`, `delta` | `patch{activity:"writing"}`; streaming `text:` row (running) | +| `session.next.text.ended` | `textID`, `text` | row done | +| `session.next.reasoning.delta`/`.ended` | `reasoningID`, `delta`/`text` | `reasoning:` row | +| `session.next.tool.input.started` | `callID`, `name` | `tool:` row running; `name==="task"` marks a spawn | +| `session.next.tool.called` | `callID`, `tool`\|`name`, `input` | `patch{activity:"tool: X"}`; row titled `describe(tool,input)`, input rendered | +| `session.next.tool.progress` | `callID`, `content[]` | row body | +| `session.next.tool.success` | `callID`, `content[]`\|`result`\|`structured` | `patch{activity:"thinking"}`; row done | +| `session.next.tool.failed` | `callID`, `error`\|`result` | `patch{activity:"thinking"}`; row `status:"error"` | +| `permission.v2.asked` | `id`, `action`, `resources[]` | `answer()` (engine.ts:750) | +| `question.v2.asked` | `id`, `questions[]` | `inquire()` (engine.ts:813) | + +Engine logic reads the activity rows back, not the bus: +- `writesOf(events, turnStarted)` reads tool rows. Its input shapes are not read here: [INFERENCE] they come from `tool.called` input keys such as `path`/`filePath`. +- Failed tool calls count as rows with `kind==="tool" && status==="error" && at>=turnStarted`. +- The "ran a shell" test is `title.startsWith("bash")`. + +So the Omp mapping must preserve tool names `bash`, `edit`, `write`, `patch`, `read`, etc., and the row title prefix. + +## 3. Shapes a replacement must produce + +- **Transcript**: `{text:string, error?:string}`. `turnText(messages)` (client.ts:477) scans messages newest first. It stops at a user message that has a top-level `text`, takes the first assistant message with non-empty joined `content[].type==="text"`, and `error` comes from the newest assistant message. +- **Message**: `{id, type:"user"|"assistant", text?, content?:Part[], error?, finish?, tokens?:{input,output,reasoning,cache:{read,write}}, model?:{providerID,id}}`. A tool part is `{type:"tool", id, name, state:{input}}`. +- **`ToolCall`**: `{id:string, name:string, input:unknown}`. +- **`StepUsage`** (graph/types.ts:212): `{messageID, model:"provider/id", tokens:{input,output,reasoning,cacheRead,cacheWrite}}`. +- **`Turn`** (server/sessions.ts): `{role:"user"|"assistant", text}`. `SessionRow`: `{id,title,agent?,model?,parent?,created,updated}`. +- **Omp native** (omp://session.md:140-165): assistant `{role:"assistant", provider, model, content:[{type:"text"}|{type:"toolCall"}], usage:{input,output,cacheRead,cacheWrite,totalTokens,cost{total}}, stopReason}`, plus separate `toolResult` messages. + - Omp has no `reasoning` token field, so map it to 0. + - Omp's `input` excludes cache; this matches the contract in usage.ts, where `contextSize` adds the cache back. + - Omp's own `cost` is available but is ignored today; the engine prices client-side. + +## 4. Who writes and reads each field + +- **`NodePatch`** has these fields (the UI type `NodeRuntime` in state.ts:20 omits `reused`, `ranShell`, `diff`). + - **`status`, `sessionID`, `output`, `error`, `prompt`, `activity`, `started`, `finished`, `reused`, `usage`**: written by engine `patch()`. + - **`diff`**: written in the orchestration batch path (`treeStat` / `mergeWorktrees`). + - **`ranShell`**: written from the judging turn, and read in `RunNodeLog` by `graph/coach`. + - **`sessionID`**: shown in inspector.tsx:213 and activity-drawer.tsx:101. + - **`prompt`**: inspector.tsx:259 ("sent prompt"). + - **`activity`**: activity-drawer.tsx:97. + - **`usage`**: spend panel and `costLabel`. +- **`EngineHooks`** (wired at app.tsx:833-860): `onNode`→`actions.patchRuntime`, `onNodeEvent`→`pushEvent`, `onRun`→`setRun(clone)`, `onNotice`, `onPermission`/`onQuestion`/`onQuestionClosed`, `onEngineStale` (opens the restart dialog). +- **`RunLog`** is built in `start()` (engine.ts:530) and persisted by `deps.saveRun`. It is checkpointed every `checkpointEvery` ms and read by the Runs menu and `announce`. The fields `rounds`, `best`, `verdict`, `usage` and per-node `steps`/`events` are written by the engine. +- **`RunOptions`** come from the UI (app.tsx:~880): `pipe`, `permissions`, `maxParallel`, `nodeTimeout`, `attachments`, `resume`, `sessions`. The rest (`questionTimeout`, `checkpointEvery`, `catalogRetry`, `rateLimitBackoff`, `toolChannel`) are test knobs. + +## D. Insertion points (server/engine.ts) + +The single choke point for every send (pipeline, swarm, orchestrate, `runSubagent`, critic, verify) is `runTurn` (871). +- The prompt text is assembled at engine.ts:948-952: `build(skipped)` + image-blind + scratch notes. +- `patch({prompt:text})` is at 953, and `api.prompt` at 955. + +**(a) Uplift**: between 952 and 953, as `text = await deps.uplift?.(node, text, {kind, reuse}) ?? text`. +- It then runs before the UI "sent prompt" and before the send. +- Jev decisions and `reassignPrompt` pass through the same line. +- Protocol re-asks (`verdictPrompt`, `forceFinalPrompt`, `dispatchResultPrompt`) pass through it too. Uplift should skip or pass them through [INFERENCE]; the `build` closures know which kind they are. + +**(b) Memory read**: also before 952, as an extra section after `build(skipped)`. Memory write goes after 1040 (`outputs.set(node.id, answer)`), before `patch status:"done"` at 1043. A write on failure or stop belongs in `finally` at 1128, next to `reconcile`. + +**(c) Swarm progress**: the Omp-specific `subscribe` handler (687-745) is the place to `patch({activity})`. The inter-card progress hooks (`activity.note`) already exist at the batch and round level (`runPeer` 1233, `orchestrate`). + +## A. Proposed `EngineApi` (names reused; new = Omp-only) + +```ts +export type Backend = Pick & { + subscribe(onEvent:(e:BusEvent)=>void, signal:AbortSignal): Promise + createSession(i:{agent?:string; model?:string; directory?:string; tools?:Record; mcp?:string[]; system?:string}): Promise<{id:string}> + prompt(sessionID:string, text:string, files?:Attachment[]): Promise + waitForIdle(sessionID:string, o?:{signal?:AbortSignal; timeout?:number}): Promise + transcript(sessionID:string): Promise<{text:string; error?:string}> + interrupt(sessionID:string): Promise + replyPermission(sid:string, reqID:string, r:PermissionReply): Promise + replyQuestion(sid:string, reqID:string, a:string[][]): Promise + rejectQuestion(sid:string, reqID:string): Promise + accepts(model:ModelInfo|undefined, mime:string): boolean + models(): Promise // {providerID,id,capabilities?:{input:string[]},cost?:Price[]} + agents(): Promise<{id:string}[]> + agentsUnscoped?(): Promise<{id:string}[]> + describe(e:unknown, r?:Response): string + sessionSteps?(sid:string): Promise + sessionCalls?(sid:string, limit?:number): Promise +} +export type EngineDeps = { api: Backend; saveRun; worktrees?; treeStat?; checkpoint?; scratch?; serveStatus?; + uplift?: (node:FlowNode, text:string, ctx:{kind:string}) => Promise // NEW + memory?: { read(node,runId):Promise; write(node,runId,out:string):Promise } // NEW +} +``` + +Implementation notes for the Omp driver, one `omp --mode rpc` child per session: +- `prompt` → `{type:"prompt",message,images}` (ack at admission, like today). +- `waitForIdle` resolves on `prompt_result`/`session_settled`. `status:"error"` carries `{message,httpStatus,retryable}` and should be rendered into the `describe` text with `HTTP 429` etc., so the regexes keep working. +- `interrupt` → `{type:"abort"}`. +- `transcript`/`sessionSteps` → `get_messages_page`. +- `models` → `get_available_models`, with a spawned process or a cached catalog. +- `createSession` → spawn the process (+`new_session`), and `set_model {provider,modelId}`. + +## B. Omp event → BusEvent mapping (from omp://rpc.md) + +| Omp frame | BusEvent | +|---|---| +| `turn_start` / `message_start` (assistant) | `session.next.step.started` (`assistantMessageID`=messageId, model from the message) | +| `message_update` `text_delta` | `text.delta` (`textID`=messageId+idx) | +| `message_update` thinking delta | `reasoning.delta` | +| `message_end` (assistant) | `text.ended` + `step.ended` (`tokens` from `usage`: input, output, reasoning 0, cache{read,write}) | +| `tool_execution_start` | `tool.input.started` + `tool.called` (`callID`=toolCallId, `tool`, `input`) | +| `tool_execution_update` / `tool_stream_update` | `tool.progress` | +| `tool_execution_end` | `tool.success` or `tool.failed` (by `isError`) | +| `auto_retry_start` | a `step.failed` note, or ignore | +| `extension_ui_request` method `ask` | `question.v2.asked` (options→`{label,description}`); `extension_ui_response` answers `{id,selectedOptions,customInput}`. Reply shape differs from `string[][]`: map positionally. | +| `subagent_lifecycle`/`subagent_event` (with `set_subagent_subscription`) | `session.created` with `parentID` plus child events | +| (none) | `permission.v2.asked`: Omp RPC has no permission-ask frames. The `policy`/`manual` path becomes a no-op unless a `tool_call` extension hook is added. | + +## E. Test compatibility + +The harness (engine.test.ts:107-424) builds `api: EngineDeps["api"]` directly and passes `deps` to `start(..., deps)` (line 424). +- Only types are imported from client.ts: `BusEvent`, `PermissionReply`, `QuestionInfo` (line 4). +- **Stable**: `start()` signature, the `EngineDeps` field names, and the BusEvent names and fields (tests emit `session.next.*`, `permission.v2.asked`, `question.v2.asked`, `session.created` literally at lines 391-417, 728-757, 943, 1863, 2547 and around 3072-3420). +- **Stable**: the optional-ness of `sessionSteps`, `sessionCalls` and `agentsUnscoped`, the `waitForIdle` option names, and `describe` text conventions. +- **client.test.ts** imports `describe`, `parseModel`, `formatModel` and `turnText` from `./client`; keep that file's exports, or move the tests with the functions. +- Other consumers of `./client` are UI (inspector, mcp-panel, project-picker, providers-panel, sessions-panel, app.tsx) and `providers.ts` (type `Integration`). They call `agents`, `health`, `connect`, `disconnect`, `integrations`, `connectKey`, `removeCredential`, `testModel`, `mcp*`, `sessions`, `sessionTranscript`. +- Adding `uplift`/`memory` as optional deps keeps all 3600 lines valid. + +## F. OpenCode-specific behaviours → Omp needs + +1. **Agents merged into opencode.json**: `agentBlock`/`agentKey` (store.ts:336-375) plus `store.saveAgents`, called from `syncAgents` (app.tsx:642). The engine's unknown-agent preflight, "stale config restart" and `onEngineStale` all stem from it. Omp has no server cache, so drop the preflight, or implement `agents()` as "the pipeline's own keys". +2. **Permission rules per tool toggle**: `TOOL_ACTIONS` (store.ts:247-258) lists `read, grep, glob, edit, bash, webfetch, websearch, todowrite, skill, question`; mcp as `"_*"`. Omp needs a per-session tool allowlist (`--tools`, or an extension) and the same names, because the engine matches `bash`/`edit` titles. +3. **Permission asks**: `answer()` (engine.ts:750) rejects `MUTATING` (`edit`,`write`,`patch`) for gauntlet critics and root. Omp has no ask frame, so it needs either tool filtering by role at spawn or a `tool_call` hook that blocks. +4. **Session directory/location, per-card worktrees**: `nodeDir` → `createSession({directory})`. Omp's equivalent is the child's `cwd`. +5. **MCP dispatch tool**: parked (`MCP_REACHES_SESSIONS=false`, graph/dispatch.ts:60). Omp can reach MCP, so `sessionCalls` could be turned on; the text fallback (`parseDispatch`) stays. +6. **Zen `-free` models**: `store.zenModels`, `providers.ts` `isFreeModel` (opencode provider, id ending `-free`), `suggestedFreeDefault`. These are meaningless on Omp; the model picker and `app.tsx:1559` free-model probe need a new source. +7. **Client-side pricing and the unpriced gate**: `summarize(steps, catalog)` needs `catalog[model].cost`. In `exhausted()` (engine.ts:1787) any unpriced model stops a gauntlet that has `maxSpend`. Omp's `get_available_models` must carry per-1M cost, or the gauntlet gate must read Omp's own `usage.cost.total` [INFERENCE; the cost field's shape in `get_available_models` was not verified]. +8. **Attachments**: data URLs go as `files`. Omp uses `images` (ImageContent) for images only; non-image files would need inlining as text. +9. **Models**: `"provider/id"` strings are split on the first `/` (`parseModel`). + +## G. Risks + +- Error text is regex-classified, so the Omp driver must emit `HTTP ` in error strings. +- Idle detection is currently polling plus a grace window; `prompt_result` is cleaner but `status:"aborted"` must not be treated as success. +- Omp processes are heavy (one per session), and reuse across `runTurn` (swarm/gauntlet critic sessions) means session loss on process death must emit "session not found" to trigger recreation. +- Message ids must be stable and unique per assistant turn, since they are the usage dedupe key. +- The usage `reasoning` field is zero, so usage under-reports reasoning-token billing if Omp folds it into `output` (it likely does); this matches the unreconciled-price convention but must be checked. +- Uplift on protocol turns may corrupt the control-block format. +- The app currently needs `opencode serve` for model, provider and key management (`integrations`, `connectKey`, `testModel`, MCP panel, sessions panel); that surface has to be replaced separately. \ No newline at end of file diff --git a/.planning/phases/01-discovery-engine-port/discovery/02-host-store.md b/.planning/phases/01-discovery-engine-port/discovery/02-host-store.md new file mode 100644 index 0000000000..98e1414366 --- /dev/null +++ b/.planning/phases/01-discovery-engine-port/discovery/02-host-store.md @@ -0,0 +1,72 @@ +## A. Route table (`handleFlow`, packages/flow/lib/store.ts:176+; mounted at `/flow/api/*`) +State: `/.openflow/{pipelines,runs(+.index.json),generated,skills,roles}` (store.ts:84-92, 1189), `~/.openflow/state.json` (last project/pipeline, `OPENFLOW_STATE_DIR`; last-session.ts:23), tmp `openflow-scratch/`, `openflow-worktrees/` (scratch.ts:25, worktree.ts:33), git refs `refs/openflow//round-N` (checkpoint.ts:29), branches `openflow//`. + +| Route | Handler / touches | Fate | +|---|---|---| +| GET context (185) | paths + recallPipeline | KEEP (+ add omp info) | +| GET/PUT roles (190) | `.openflow/roles` | KEEP | +| GET/PUT/DELETE pipelines[/:name] (198) | `.openflow/pipelines`, state.json; 409 on slug clash | KEEP | +| POST pipelines/:name/agents?merge=1 (writeAgents, store.ts:~700) | writes `.openflow/generated/.opencode.json`; merge folds into **global** `~/.config/opencode/opencode.json[c]` (+`.bak`/`.prev.bak`) | DELETE (replace by per-card launch spec, C) | +| GET/PUT/DELETE mcp[/:name] (248) | project `opencode.json` `mcp` block | ADAPT → target `.omp/mcp.json` (omp mcp-config.md; omp also still reads `opencode.json` mcp) | +| GET/PUT/DELETE skills[/:name] (264) | `.openflow/skills//SKILL.md`; `registerSkillSource` writes `skills.paths` into project opencode.json (1278) | ADAPT: keep SKILL.md I/O, delete registerSkillSource; deliver via omp skills discovery/`--skills` [INFERENCE: omp skill dirs] | +| GET cli-keys, POST cli-keys/import (282) | reads `$XDG_DATA_HOME/opencode/auth.json`/`OPENCODE_AUTH_CONTENT`, POSTs `/api/integration//connect/key` upstream | DELETE (omp has own auth: `omp auth`, RPC `get_login_providers`/`login`) | +| GET env?names= (299) | names-only presence | KEEP | +| GET browse, POST pick-folder (312,374) | fs / native dialog, `allowsRemote()` guard | KEEP | +| GET/POST/DELETE dispatch-tool (321) | install/uninstall MCP entry in global opencode config; restart:true | DELETE (host tools, D) | +| GET/POST repackage (330) | `COMPATIBLE_PROFILES` → global config `provider..npm` | DELETE (omp providers/models.md own it) | +| GET server, POST server/restart (345) | `request.serve` Supervisor status/restart; 403 if remote, 409 if unmanaged | REPLACE by `/omp/*` status/restart-session | +| POST project (391) | `setProjectPath` mutates FlowPaths, `rememberProject` | KEEP | +| GET zen-models (405) | fetches opencode.ai/zen/v1/models | DELETE (+lib/zen.ts, zen.test.ts) [src/server/providers.ts consumer needs rewrite] | +| GET treestat (416), POST checkpoint (423), POST scratch (430), POST worktrees / merge / cleanup (435+), GET/PUT/DELETE runs[/:id], POST runs/prune (446+) | git + `.openflow/runs` | KEEP (worktree dir becomes omp `--cwd`) | + +Env vars: `OPENCODE_SERVER_URL` (server.ts:23, vite.config.ts:25, flow-store.ts:19, launch.ts, .sh/.ps1) DELETE; `OPENCODE_SERVE_COMMAND`, `FLOW_MANAGE_SERVER` DELETE; `OPENCODE_AUTH_CONTENT`, `XDG_DATA_HOME` (cli-auth) DELETE; KEEP `OPENFLOW_PROJECT`, `OPENFLOW_STATE_DIR`, `FLOW_PORT`(5174), `FLOW_HOST`, `FLOW_ALLOW_REMOTE` (guard.ts:23), `OPENFLOW_BUILT`, `OPENFLOW_DRY_RUN`; `FLOW_PROXY_TIMEOUT` DELETE unless a proxy remains. + +## B. Proxy / SSE +- Bun host: `PROXIED=["/api","/global","/event","/mcp"]` (server.ts:53) → `proxy()` (148-199) does `fetch(upstream+path)` with `duplex:"half"`, `redirect:"manual"`, `AbortSignal.timeout(30s)` except `STREAMED=["/event"]` (no deadline); one `connection: close` retry for timed-out GETs (stale keepalive after restart); errors → `{message}` 504/502 (key `message` is read by browser `describe()`); `relay()` (212) strips `content-encoding/content-length` because fetch un-gzips. `Bun.serve idleTimeout:0` (111) keeps SSE alive. SSE needs no special code: Response body stream is returned untouched. +- Vite host: `server.proxy` (vite.config.ts:90-98) http-proxy, `ws:false`, `/event` with `timeout:0, proxyTimeout:0`; `configure` error hook → JSON `{message}`. +- Dependents: client.ts:35 `createOpencodeClient({baseUrl: location.origin, directory})`; all `client.v2.*` (health, agent.list, model.list, integration.*, credential.remove, session.create/list/messages/prompt/interrupt, permission.reply, question.reply/reject, event.subscribe client.ts:588-590 `sseMaxRetryAttempts:0`), `client.mcp.status/connect/disconnect` (421-437, `/mcp`), `agentsUnscoped` (120), usage reconciliation from message history (551), `sessionCalls` (516). Also launcher/supervisor `healthy()` → `/api/health` (opencode-process.ts:71). With omp there is no upstream: delete both proxies; browser consumes a host-owned REST+SSE surface (G) and client.ts becomes an omp-RPC-backed adapter (engine.ts only touches `src/server/client.ts` via `api.*`, store.ts via `store.*`). + +## C. Agents → Omp +Current: `agentBlock(pipeline, servers)` (src/server/store.ts:362) → per node `agentKey` = `${pipeline.name}-${role}-${node.id}` sanitized lowercase (336); value `{mode:"primary", description:"OpenFlow node () of pipeline " (marker used by `generatedFor` to prune stale entries), model?, prompt?, permission?}`. `permissionBlock` (319): `toolMap` folds aliases write/patch/apply-patch→edit, deny wins; only names in `TOOL_ACTIONS` (read grep glob edit bash webfetch websearch todowrite skill question); emits `allow|deny`; unnamed tools inherit default agent (validate.ts:121-128 warns). `mcpBlock`: `_*` allow/deny only for configured servers. Config is read once at boot → "merge agents + restart engine" (store.ts:~700, app.tsx:645). + +Omp (one `omp --mode rpc` per card session; cli-reference.md): +- `--cwd ` (worktree.ts `OpenedTree.directory`); `--session-dir <.openflow/omp-sessions/>` or `--no-session`. +- `--model ` from `node.agent.model` (fuzzy ok); `--thinking ` optional. +- `--append-system-prompt ` (write node.agent.prompt to `.openflow/generated//.md`; avoids argv limits and keeps omp's coding prompt; `--system-prompt` would replace it). +- `--tools a,b,c` allowlist: read→`read`, grep→`grep`, glob→`glob`, edit→`edit,write`, bash→`bash`, websearch→`web_search`, webfetch→`read` (URL read; [INFERENCE]), todowrite→`todo`, question→`ask`, skill→ none (skills via `skill://`; [INFERENCE]). Docs: `--tools` "validates against the fully discovered session registry including custom tools" (custom-tools.md:174). Semantics of default when node declares nothing = all tools (parity with today's hazard). Verify with `omp --tools bogus` error text. +- `--approval-mode yolo|write|always-ask`: no per-tool allow/deny in CLI; use a generated `--config ` per card with `tools.approval.: deny|allow|prompt` (approval-mode.md) — gives true deny parity incl. MCP: `tools.approval.mcp___`; per-server wildcard not documented → expand to tool names or rely on `--tools`/`--no-extensions`. `prompt` in RPC surfaces as `extension_ui_request` [INFERENCE] → replaces permission.reply/question.reply UI. +- MCP per node: `agent.mcp` allowlist → per-card overlay `mcp.json` disable via `disabledServers`/`enabled:false` (mcp-config.md) rather than permission rules. +- Env: `PI_CODING_AGENT_DIR`/`PI_CONFIG_DIR`/`OMP_PROFILE` (isolated profile), `OPENFLOW_RUN/CARD`, `SUBSTRATE_*` pass-through (never logged). +- Replacement for merge+restart: a pure `omp-launch.ts` `cardSpec(pipeline,node,run) -> {argv,env,cwd,files}` (reuse `toolMap`, `agentKey`); host spawns a fresh child per session so config changes apply at next spawn; mid-session changes use RPC `set_model`, `set_thinking_level`. Drop `generatedFor`/stableEqual/backup logic and `.opencode.json` generation; `/agents` route becomes `POST /omp/cards/:key/spawn` (or preview of the spec). + +## D. Dispatch +Current: `mcp/dispatch.ts` is a hand-rolled stdio JSON-RPC server (server name `openflow`, tools `dispatch{assignments[{card,task,files?}]}` and `finish{answer}`; replies "Stop here and end your turn"); records nothing. `dispatch-tool.ts` writes `mcp.openflow={type:"local",command:[bunAbs, mcp/dispatch.ts],enabled}` to the GLOBAL opencode config (needs restart). `graph/dispatch.ts`: `DISPATCH_TOOL="openflow_dispatch"`, `FINISH_TOOL`, `fromToolCall(name,input,children)` (142) shares validation with `parseDispatch` (fenced ```openflow block, last block wins). **`MCP_REACHES_SESSIONS=false` (dispatch.ts:60)** — v2 runner never exposed MCP, so today only the fenced block works; engine.ts:403 `toolChannel`, 1852 `api.sessionCalls(...)` (client.ts:516 scans assistant tool parts) are dormant; app.tsx:534,1436 gate the banner. +Omp options: (1) **RPC host tools (recommended)**: after `ready`, send `set_host_tools` with `openflow_dispatch`/`openflow_finish` (same JSON schemas as TOOLS in mcp/dispatch.ts:~30-80, `loadMode:"essential"`); omp emits `host_tool_call{id,toolCallId,toolName,arguments}`, host answers `host_tool_result{content:[{type:"text",text:"Dispatched. Stop here…"}]}` and records the decision itself → authoritative, no restart, no global config, tool names stay `openflow_dispatch/finish` so `DISPATCH_TOOL/FINISH_TOOL/fromToolCall` and engine tests (engine.test.ts:2131,2155) are unchanged. Flip `MCP_REACHES_SESSIONS`→true (rename `TOOL_CHANNEL`); `sessionCalls` reads from host-captured calls or `get_messages` toolCall blocks. Caveat: `--tools` allowlist is validated before host tools exist → verify host tools aren't filtered [INFERENCE]. (2) MCP via `--config` overlay/`.omp/mcp.json`: name becomes `mcp__openflow_dispatch` (server prefix deduped; mcp-server-tool-authoring.md:168) → must change both constants; keeps mcp/dispatch.ts. (3) agent-swarm extension tool via `-e` — name is whatever the extension registers. Delete mcp/dispatch.ts + dispatch-tool.ts(+test) under (1). + +## E. Launcher +No `opencode serve`: no engine port 4096, no `/api/health`, no managed/unmanaged. `launchPlan` (launch.ts:44-95) → only `{canvas}` plan; delete `engine`, `engineUrl`, `managed`, `DEFAULT_ENGINE_URL`; keep `built`, `canvasUrl` 5174, probe. openflow.ts: drop `healthy` import (22), engine freePort/spawn/wait loop (~150-190), `ENGINE_TIMEOUT`; probe becomes `reachable` only; keep `freePort`, `killTree`, `openBrowser`, `OPENFLOW_DRY_RUN` output (remove engine lines). openflow.sh: delete `-s/--server-port`, `-m/--manage`, `OPENCODE_SERVER_URL` export, usage text about "api keys"; openflow.ps1: `-ServerPort`, `-Manage`, `$env:OPENCODE_SERVER_URL`, `FLOW_MANAGE_SERVER`, help text. Add preflight `omp --version` (≥18.12) in host, not launcher (surface in Omp settings). Restart button/`engine-dialog.tsx`/`showEngineHelp` (app.tsx:342-376) → "restart card session" or removed. Root package.json: `description` ("built on the opencode engine"), `scripts.dev` runs opencode (root package.json:9) — fork-owned? root package.json is upstream-shared; do not edit (FLOW.md rule); flow's own scripts (`dev`,`serve`,`start`) stay. `@opencode-ai/sdk` dep in packages/flow/package.json:19 stays only while client.ts imports types (`AgentV2Info`, `ModelV2Info`). + +## F. Delete / rewrite checklist +- server.ts: 3 (serveCommand/supervisorFor import), 10-21 doc, 23 `upstream`, 33 `repo`, 34-50 engine, 52-65 PROXIED/STREAMED/PROXY_TIMEOUT, 78-96 boot+logs, 112-117 proxy dispatch, `upstream`/`serve` in handleFlow call (125-126), 147-218 proxy+relay. Keep statics, body, listen skeleton. +- vite.config.ts: 24-27 upstream, 29-33 doc, 35-79 PROXY_TIMEOUT/proxy, 90-98 server.proxy, `flowStore({upstream})` arg. +- vite/flow-store.ts: 4, 19-45 supervisor, 49-50 close hook, 59-60 `upstream/serve` args. +- lib/store.ts: header doc 14-17,56-72; imports 4,7,8 (cli-auth, Supervisor, repackage, dispatch-tool, zen); FlowRequest.upstream/serve (162-172); routes cli-keys 282-297, dispatch-tool 321-328, repackage 330-343, server 345-372, zen 405-412, agents 214-217; fns writeAgents..stableEqual (~690-800), MCP funcs retarget (800-962), readProjectConfig/readGlobalConfig/stripJsonc/CONFIG_HAS_COMMENTS (965-1190), packageRoot/bunPath/dispatchToolStatus/applyDispatchTool/repackage* (1013-1127), SKILL_SOURCE/registerSkillSource (1190,1278-1300), backupConfig (keep only if reused). +- Whole files delete: lib/opencode-process.ts(+test) (keep `killTree` logic for omp children), lib/cli-auth.ts(+test), lib/repackage.ts(+test), lib/zen.ts(+test), lib/dispatch-tool.ts(+test), mcp/dispatch.ts (if D1). Rewrite: lib/launch.ts(+test), openflow.*, README.md (lines 5,15,51-69,76-134,147,162-175,203+), FLOW.md (opencode-engine sections ~25-35,98,174-195,744-773), package.json descriptions. +- src: src/server/store.ts (agentBlock/permissionBlock/mcpBlock/TOOL_ACTIONS, saveAgents, serverStatus/restartServer, cliKeys, zenModels, repackage, dispatchTool*), client.ts (whole v2 adapter), app.tsx (41-42,305,328-376,511-534,581,645-650,1430-1440), providers-panel.tsx, engine-dialog.tsx, mcp-panel.tsx, graph/validate.ts:1,121-147, graph/dispatch.ts:33-60 comment. + +## G. Mounting +Shared module `lib/omp-host.ts` (Web-standard): `ompRoutes(ctx): (req: Request)=>Response|Promise|undefined` for `/omp/api/*` REST (settings, sessions spawn/stop/prompt, models) and `/omp/events` SSE (`ReadableStream` of `text/event-stream`, fan-out of each child's RPC stdout frames tagged `{session, frame}`), plus `ptyHandlers` `{open,message,close}` for `/omp/pty`. +- Bun (server.ts:112): in `fetch`, before statics: `if (url.pathname.startsWith("/omp/")) { if (url.pathname==="/omp/pty") return server.upgrade(request,{data:…}) ? undefined : new Response("upgrade",{status:426}); return ompRoutes(request) }`; add `websocket: ptyHandlers` to `Bun.serve`; `idleTimeout:0` already set. Alternatively fold the REST into `handleFlow` under `/flow/api/omp/*` (zero new mounting; same JSON `{status,body}` shape) and only add SSE+WS as extras. +- Vite (flow-store.ts): `server.middlewares.use("/omp", nodeAdapter(ompRoutes))` — convert IncomingMessage→Request, pipe `Response.body` to `res` with `no-cache`, `res.flushHeaders()`; WS: `server.httpServer.on("upgrade",(req,socket,head)=>{ if(req.url!=="/omp/pty") return; wss.handleUpgrade(...)})` ignoring vite's HMR path (`vite-hmr` protocol); `ws` is a transitive dep of vite (not a declared flow dep) [INFERENCE]. Vite 7.1.4 root catalog; dev server runs under node (store.ts bunPath comment) so PTY must work in both runtimes: Bun ≥1.3.5 `Bun.spawn({terminal})`; under node `@lydell/node-pty` (root catalog package.json:95, core dep, postinstall `fix-node-pty`). Cleanest: run vite with `bun --bun vite` so both hosts are Bun and `Bun.spawn` terminal is the only PTY path [INFERENCE]. +- Keep `remoteBindRefusal` + `allowsRemote()` (guard.ts) in front of every new route; PTY/spawn are remote-code-exec, refuse unless loopback. + +## H. Risks +1. Dev host runs under node, prod under Bun: PTY, `Bun.spawn`, WS must be runtime-neutral or force `bun --bun`. +2. Host-tool/`--tools` interplay and `prompt`-approval-over-RPC are [INFERENCE]; probe with a throwaway `omp --mode rpc` before design freeze. +3. Single in-process serialization (`serialize()`, `chains`) protects only one host; two hosts same project still race (store.ts:1431). +4. Run state lives in the page (FLOW.md:174): resume relied on server-side sessions; with child-per-session owned by host, a host restart kills all sessions → need `--session-dir` + `--resume `. +5. Orphan children: reuse process-group kill (opencode-process.ts:97-122) and SIGINT/SIGTERM teardown on both hosts; vite `httpServer.close` hook. +6. Secret hygiene: SUBSTRATE_TOKEN/OPENROUTER_API_KEY must be env-passed to children, never into argv, overlay files, SSE frames or logs; `/flow/api/env` stays names-only. +7. Models/credentials UI (providers-panel, spend/usage from `usage.ts`, `isFreeModel`) depend on opencode catalog shapes — largest src rewrite. +8. Existing global `~/.config/opencode/opencode.json` MCP/agent entries are still ingested by omp (mcp-config.md "OpenCode" import) → stale `mcp.openflow` entry would register a duplicate `mcp__openflow_*` beside host tools; installer cleanup/`uninstall` once at migration. +9. Tests deleted/rewritten: launch.test, opencode-process.test, store.test (routes), engine.test (client fakes), validate.test (permissionBlock). \ No newline at end of file diff --git a/.planning/phases/01-discovery-engine-port/discovery/03-ui-coupling.md b/.planning/phases/01-discovery-engine-port/discovery/03-ui-coupling.md new file mode 100644 index 0000000000..04f35e865b --- /dev/null +++ b/.planning/phases/01-discovery-engine-port/discovery/03-ui-coupling.md @@ -0,0 +1,83 @@ +All paths under packages/flow/src. Read-only; nothing run. + +## A. Per-file coupling inventory (opencode-specific → Omp need) + +**app.tsx (1834 lines)** +- 28 `./server/engine` (browser-side run engine: `start`, `Run`, `PermissionPolicy`, `PipeMode`, `DEFAULT_*`); 29-35 `./server/providers`; 36 `setAvailableModels/setDefaultModel`; 45-52 `agentBlock/agentKey/McpServer/ServeStatus` from store; 25 `MCP_REACHES_SESSIONS`. +- 156-170 `namesEngine()` string-matches "opencode serve", "Failed to fetch", "HTTP 502" → the engine-dead heuristic; rewrite for the Omp host/RPC errors. +- 203-221 signals: `showProviders/showSkills/showMcp/showSpend`, `engine`, `restarting`, `engineHelp/engineWhy`, `mcpServers`, `dispatchTool`, `installing`. +- 263-308 `probe()`/`bootstrap()` call `api.health()`, `api.agents()`, `refreshProviders()`, `store.serverStatus()`; 303 message "cannot reach opencode serve". +- 311-380 `installDispatchTool`, `showEngineHelp`, `restartEngine` (`store.restartServer`, `api.disconnect`). +- 488-519 `refreshProviders()` = `api.integrations()` + `api.models()` + `store.envPresent` + `store.zenModels` → `providerRows(...)`, plus `serves = Map([["opencode", …]])` (516). Replace with Omp model list (`get_available_models` over RPC [INFERENCE: name from omp RPC docs, not verified]). +- 520 `openProviders()`; 527-535 `refresh()` (dispatch tool, pipelines, runs, `store.mcpServers`); 1794-1821 `readCatalog/readModels` retry loops (cold-catalog race; opencode-only). +- 629-690 `syncAgents()`/`mergeAgents()`: writes `agentBlock` into project `opencode.json` and verifies `api.agents()`; 671 "restart opencode serve". Under Omp there is no config merge: per-node tools/model/prompt go as RPC/spawn args → delete. +- 786-815 `run()` preflight with `unlockedModels`, `engineReachable`, `showEngineHelp`; 817-830 calls `syncAgents()` before run. +- 840-905 `start(pipeline,input,hooks,opts)`; hooks `onNode,onNodeEvent,onRun,onNotice,onPermission,onQuestion,onQuestionClosed,onEngineStale`. Uplift/memory/swarm hooks plug in here. 905-940 post-run: `namesEngine` → `showEngineHelp`. +- 1035-1180 titlebar buttons (see D). 1564 `api.testModel` in Walkthrough `onUseFree`. 1578-1595 mounts Providers/Skills/Mcp panels; 1615-1625 EngineDialog; 1627 SpendPanel. +- 1658-1780 statusbar. + +**ui/providers-panel.tsx**: `api.connectKey`, `api.removeCredential` (106,120), `store.cliKeys/importCliKeys` (73,134), `store.repackage*` (74,161), copy "stored by the running opencode server" (300), FEATURED opencode list. Delete (Omp keys: env vars / `omp` auth/`/login`, `agent.db`; handled in terminal or Omp settings). +**ui/mcp-panel.tsx**: `store.mcpServers/saveMcpServer/deleteMcpServer` (writes `opencode.json`), `api.mcpStatus/mcpConnect/mcpDisconnect`; copy "Restart opencode serve". `splitCommand` (441) is pure and tested (mcp-panel.test.ts). Rewrite to Omp's MCP config (`.omp/mcp.json` [INFERENCE]; check `read omp://mcp-config.md`). +**ui/skills-panel.tsx**: `store.skills/skill/saveSkill/deleteSkill`; registers `.openflow/skills` in opencode.json; "restart opencode serve" copy (98,119,233). Omp discovers `SKILL.md` natively (skill:// URLs) [INFERENCE] → keep editor, change target dir and drop restart text. +**ui/engine-dialog.tsx**: whole file = restart `opencode serve` (`ServeStatus`, `FLOW_MANAGE_SERVER`, port-kill commands, launcher). Delete; replace by per-session "Restart Omp session" in Omp surface. +**ui/sessions-panel.tsx**: `api.sessions()`, `api.sessionTranscript()`, `server/sessions.ts` (SessionRow = opencode `Session.Info`, `transcriptTurns` of opencode message shapes). Rewrite: Omp session files (`~/.omp/agent/sessions`) or host-listed cards' sessions. +**ui/model-picker.tsx**: depends on `ProviderRow` and `freeModels`; 227 "`opencode serve` is not answering". Keep component, feed from Omp model list, fix copy and free/zen logic. +**ui/spend-panel.tsx**: `store.runs`, `usage.ts`; 87 copy "relayed by opencode serve", models.dev prices. Keep; Omp RPC reports usage/cost directly, so pricing from catalog may be unnecessary. +**ui/question-dialog.tsx**: shape `QuestionInfo` from client.ts (opencode question tool). Omp equivalent is the `ask` tool / RPC `extension_ui_request` [INFERENCE]; keep dialog, adapt type. +**ui/activity-drawer.tsx**: opencode-neutral; shows `sessionID` (101-103). Event model `NodeEvent` is generic → map Omp RPC events to it. +**ui/inspector.tsx**: 107 "opencode agent" Select fed by `api.agents()`; 43-50 `api.testModel`; 154 add MCP; sections session (213), cost (225), sent prompt (259), error (266), output (273). Agent select must go or become "Omp agent/subagent type". +**ui/walkthrough.tsx**: step `engine` text (94-103) "opencode serve…`bun openflow.ts`"; `provider` step counts `unlockedProviders`. **ui/palette.tsx, role-editor.tsx, coach-strip.tsx, attachments.tsx, select.tsx, icons.tsx**: no engine coupling; role-editor imports `TOOLS` from store.ts (tool allowlist names = opencode tool vocabulary; Omp tool names differ: read, edit, write, bash, grep, find, …). +**canvas/node-card.tsx**: neutral; `hasActivity` uses `sessionID` (31). +**server/providers.ts**: whole file opencode catalog logic: `runnable()` (@ai-sdk package routing, 66), `FEATURED` (118), `isFreeModel` = provider `opencode` + `-free` suffix (236), `providerRows(integrations, models, env, serves)` (139), `splitProviders`, `searchModels`, `groupMatches`, `unlockedRows/readyRows/availableModels`. Rewrite to Omp model list; keep `ModelOption.value = "provider/model"` so default-model/validate keep working. +**server/store.ts**: `request` → `/flow/api` (host); `ServeStatus` (64), `mcpServers/*`, `skills/*`, `saveAgents` (/pipelines/:n/agents), `cliKeys`, `zenModels`, `envPresent`, `importCliKeys`, `repackage*`, `dispatchTool*`, `serverStatus`, `restartServer` (216). Keep roles/pipelines/runs/worktrees/treestat/checkpoint/scratch/browse/pickFolder/setProject. `TOOL_ACTIONS/TOOLS/TOOL_HELP`, `toolMap`, `permissionBlock`, `agentKey`, `mcpBlock`, `agentBlock` (362) are opencode `agent` config generators → delete or reshape to Omp tool lists. +**server/sessions.ts**: opencode session projection (see sessions-panel). **server/activity.ts**: consumes opencode SSE types `session.next.text.delta`, `tool.input.started`, `tool.called/success/failed`, `step.started`, `session.created`, subagent via `task` tool (18) → rewrite to Omp RPC event stream (`message_update`, `tool_execution_*` [INFERENCE]). **server/usage.ts**: `summarize(steps, catalog)` prices opencode token reports; neutral logic, input shape changes. **server/runs.ts**: pure run-menu helpers; no coupling (RunEntry only). +**state.ts**: `engineReachable` (doc names `opencode serve`, 57-67), `NodeRuntime.sessionID/prompt`, `PendingPermission`, `PendingQuestion` (imports `QuestionInfo` from client.ts), `actions.askPermission/askQuestion`. +**graph/types.ts**: `FlowAgent.name` "Existing opencode agent" (40); `NodeEvent.sessionID/parentCallID` (258-260); `RunNodeLog.sessionID/permissions/questions/prompt/model/agent/steps`. **graph/validate.ts**: `preflight(ctx{unlockedModels, engineReachable})`, message at 92, rules `unrestricted-write`/`duplicate-agent` (124-152) are about agentBlock. **graph/prompt.ts**: 58, 169, 514, 584, 640-643 say "a plain `opencode` session… You share no memory with any other card" (false once substrate memory exists). **graph/roles.ts** 12/60 comments only. **graph/default-model.ts**: neutral (localStorage `openflow.defaultModel.v1`). **graph/orchestration.ts**: comment only. +**styles.css**: header comment "OpenFlow wears opencode's clothes" (1-27), `.oc*` class prefix, `--oc-*` aliases; cosmetic only. + +## B. Panel disposition +- ProvidersPanel: DELETE → fold into Omp surface "Models & keys" (model list + env/auth status + "open in terminal to /login"). +- McpPanel: REWRITE (Omp MCP config), merged as tab of Omp dialog; keep `splitCommand` and its test. +- SkillsPanel: REWRITE lightly (path + copy), merged as tab. +- EngineDialog + titlebar restart button + `engineHelp/engineWhy/restarting`: DELETE; replaced by per-card "restart session" and Omp-settings status. +- SessionsPanel: REWRITE (Omp sessions) – keep left column and its persisted-open flag. +- SpendPanel: KEEP, fix copy/inputs. +- ModelPicker: KEEP, repoint data. QuestionDialog/ActivityDrawer/Inspector/Walkthrough: KEEP + edit. Titlebar "api keys", "skills", "restart engine", "mcp servers", "merge agents" (IconLayers) collapse into ONE "Omp" button (IconSliders/new IconTerminal) opening the Omp settings dialog; "merge agents" removed. + +## C. Card display insertion points +Today: node-card header badge (`node-card.tsx` 89-93), diff chip (97-106); body: model (110), agent (113), activity line (116), error (119), output preview (125), "show activity" button (131-147). Inspector (`inspector.tsx`): session id (213), cost (225-257), "sent prompt" `
` (259-264), error (266), output (273). Drawer (`activity-drawer.tsx`): header bar badge/model/elapsed/cost/activity/sessionID (78-100), event stream, final error `
` (131).
+(a) original vs uplifted: `runtime.prompt` is the single sent-prompt field, set at engine.ts:953 (`patch(node.id,{prompt:text})`) right before `api.prompt` (955). Add `NodeRuntime.originalPrompt`/`uplift` (+ same on `RunNodeLog`) and render two `panel-section` blocks ("original prompt" / "uplifted prompt", plus mode/score/diff) in inspector.tsx at 259; a small "uplifted" chip in node-card header next to the badge.
+(b) memory read/write: chips in node-card header (like `.node-diff` 97) "mem ↓n ↑m" with title listing keys; detail list in inspector as new `panel-section` after cost (257); also as `NodeEvent` kind `note` rows in the drawer (types.ts kind union has "note" already).
+(c) swarm progress: statusbar-left run progress (app.tsx 1667-1727 already per-node pills) + a node-card line under `activity` (116) for swarm peers/claims; in the drawer bar (78-100).
+(d) "Run with original prompt": mirror question-dialog: a blocking modal using `actions.askQuestion`-style promise in state.ts (new `asking`-like map `uplift decisions`), mounted in app.tsx next to `` (1607) as ``; buttons "Run with original prompt" / "Cancel card" (cf. Send answer/Skip, question-dialog 90-108). Alternative lightweight: `.permissions` bar (app.tsx 1520-1547, `.permission` row with allow/reject) — better for non-blocking, but a failure that gates a prompt should block → modal. Note permission prompts are only used under `manual` policy; questions are modal, one at a time.
+
+## D. Conventions
+- Open panels: `titlebar-actions` (app.tsx 1048-1180) `
           
           
           
-          
         
       
@@ -1406,13 +1348,33 @@ export function App() {
 
       
         {(banner) => (
-          
+
}> {banner().text} + + + -
- - {(pre) => (
@@ -1491,7 +1427,7 @@ export function App() { -
+
{(request) => (
@@ -1533,58 +1469,50 @@ export function App() { project={project()} onClose={() => { setShowSessions(false) - writeShowSessions(false) + writeFlag(SESSIONS_KEY, false) }} /> setShowOmp(true)} onManageMcp={() => setShowMcp(true)} /> openProviders()} - onUseFree={async () => { - // suggestedFreeDefault picks by catalog order, not liveness — the free - // tier's health drifts hour to hour, so probe each candidate with a real - // prompt (same check the per-node "test" button runs) until one answers. - const candidates = freeModels(providers()) - if (!candidates.length) return - actions.notice("info", "checking free models…") - for (const candidate of candidates) { - const result = await api.testModel(candidate.value) - if (!result.ok) continue - setDefaultModel(candidate.value) - actions.notice("info", `default model set to ${candidate.value} — new nodes will use it`) - return - } - actions.notice("error", "no free model answered right now — try again in a moment") - }} + ompReady={ompReady()} + ompMessage={ompReady() ? undefined : ompMessage()} + modelCount={availableModels(providers()).length} + onOpenOmp={() => setShowOmp(true)} + onOpenTerminal={() => openTerminal("omp")} /> - {/* Below the graph, above the status bar: the expanded card. */} + {/* Below the graph, above the status bar: the expanded card, or the + terminal — never both, see `setTerminal`. */} + setTerminal(false)} + kind={terminalKind()} + onOpenSettings={() => setShowOmp(true)} + /> - - setShowProviders(false)} - onChanged={refreshProviders} - onNotice={notice} - onRestart={engine()?.managed ? restartEngine : undefined} - /> - + setShowOmp(false)} + onOpenTerminal={() => { + setShowOmp(false) + openTerminal("omp") + }} + onChanged={() => void reconnect()} + pipeline={state.pipeline.name} + /> setShowSkills(false)} onNotice={notice} /> @@ -1608,22 +1536,22 @@ export function App() { - - setShowProjectPicker(false)} onSwitched={switchProject} /> - - - - {(status) => ( - void restartEngine() : undefined} - onClose={() => setEngineHelp(undefined)} + {/* A card is parked on this: nothing has been sent to it. One at a time, + keyed on the failure so each gets a fresh dialog and a fresh focus restore. */} + + {(failure) => ( + )} + + setShowProjectPicker(false)} onSwitched={switchProject} /> + + setShowSpend(false)} /> @@ -1659,7 +1587,7 @@ export function App() {
+ {/* What happened to the card's task around the turn: uplift outcome and + shared-memory counts. Each renders nothing until it has a record. */} + +
@@ -113,6 +118,7 @@ export function NodeCard(props: {
{runtime().activity}
+
{runtime().error}
diff --git a/packages/flow/src/graph/__fixtures__/legacy/pipeline-agent-name.json b/packages/flow/src/graph/__fixtures__/legacy/pipeline-agent-name.json new file mode 100644 index 0000000000..edf0dbaefa --- /dev/null +++ b/packages/flow/src/graph/__fixtures__/legacy/pipeline-agent-name.json @@ -0,0 +1,29 @@ +{ + "id": "fixture-agent-name", + "name": "named agents", + "nodes": [ + { + "id": "n1", + "role": "builder", + "agent": { + "name": "build", + "model": "anthropic/claude-sonnet-4-5", + "prompt": "Build it.", + "tools": { "read": true, "write": true }, + "mcp": ["github"] + }, + "position": { "x": 40, "y": 40 } + }, + { + "id": "n2", + "role": "reviewer", + "agent": { + "name": "", + "model": "openrouter/anthropic/claude-haiku-4.5", + "prompt": "Review it." + }, + "position": { "x": 340, "y": 40 } + } + ], + "edges": [{ "id": "e1", "source": "n1", "target": "n2" }] +} diff --git a/packages/flow/src/graph/__fixtures__/legacy/pipeline-bad-models.json b/packages/flow/src/graph/__fixtures__/legacy/pipeline-bad-models.json new file mode 100644 index 0000000000..cb8e41424e --- /dev/null +++ b/packages/flow/src/graph/__fixtures__/legacy/pipeline-bad-models.json @@ -0,0 +1,14 @@ +{ + "id": "fixture-bad-models", + "name": "bad models", + "nodes": [ + { "id": "n1", "role": "no-slash", "agent": { "model": "gpt-4", "prompt": "x" }, "position": { "x": 0, "y": 0 } }, + { "id": "n2", "role": "no-id", "agent": { "model": "anthropic/", "prompt": "x" }, "position": { "x": 1, "y": 0 } }, + { "id": "n3", "role": "no-provider", "agent": { "model": "/claude", "prompt": "x" }, "position": { "x": 2, "y": 0 } }, + { "id": "n4", "role": "not-a-string", "agent": { "model": 7, "prompt": "x" }, "position": { "x": 3, "y": 0 } }, + { "id": "n5", "role": "mixed-case", "agent": { "model": "OpenCode/Big-Pickle", "prompt": "x" }, "position": { "x": 4, "y": 0 } }, + { "id": "n6", "role": "blank", "agent": { "model": "", "prompt": "x" }, "position": { "x": 5, "y": 0 } }, + { "id": "n7", "role": "fine", "agent": { "model": "openrouter/openrouter/free", "prompt": "x" }, "position": { "x": 6, "y": 0 } } + ], + "edges": [] +} diff --git a/packages/flow/src/graph/__fixtures__/legacy/pipeline-diamond.json b/packages/flow/src/graph/__fixtures__/legacy/pipeline-diamond.json new file mode 100644 index 0000000000..ec7b03bd48 --- /dev/null +++ b/packages/flow/src/graph/__fixtures__/legacy/pipeline-diamond.json @@ -0,0 +1,108 @@ +{ + "id": "83a84560-33cf-49a7-a7ac-683b591157cf", + "name": "diamond", + "nodes": [ + { + "id": "nmsqpvewx1", + "role": "planner", + "agent": { + "prompt": "You are the planner. Answer in ONE sentence. Never call any tool. Never read files. Answer only from the text given.", + "tools": { + "read": true, + "grep": true, + "glob": true, + "write": false, + "edit": false, + "bash": false + }, + "model": "opencode/nemotron-3.5-lightning-free" + }, + "position": { + "x": 40, + "y": 40 + } + }, + { + "id": "nmsqpvewy2", + "role": "worker-a", + "agent": { + "prompt": "You are the worker-a. Answer in ONE sentence. Never call any tool. Never read files. Answer only from the text given.", + "tools": { + "read": true, + "grep": true, + "glob": true, + "write": true, + "edit": true, + "bash": true + }, + "model": "opencode/nemotron-3.5-lightning-free" + }, + "position": { + "x": 340, + "y": 40 + } + }, + { + "id": "nmsqpvewy3", + "role": "worker-b", + "agent": { + "prompt": "You are the worker-b. Answer in ONE sentence. Never call any tool. Never read files. Answer only from the text given.", + "tools": { + "read": true, + "grep": true, + "glob": true, + "write": true, + "edit": true, + "bash": true + }, + "model": "opencode/nemotron-3.5-lightning-free" + }, + "position": { + "x": 640, + "y": 40 + } + }, + { + "id": "nmsqpvewy4", + "role": "reviewer", + "agent": { + "prompt": "You are the reviewer. Answer in ONE sentence. Never call any tool. Never read files. Answer only from the text given.", + "tools": { + "read": true, + "grep": true, + "glob": true, + "write": false, + "edit": false, + "bash": false + }, + "model": "opencode/nemotron-3.5-lightning-free" + }, + "position": { + "x": 40, + "y": 240 + } + } + ], + "edges": [ + { + "id": "emsqpvf7a0", + "source": "nmsqpvewx1", + "target": "nmsqpvewy2" + }, + { + "id": "emsqpvf9x1", + "source": "nmsqpvewx1", + "target": "nmsqpvewy3" + }, + { + "id": "emsqpvfcj2", + "source": "nmsqpvewy2", + "target": "nmsqpvewy4" + }, + { + "id": "emsqpvff53", + "source": "nmsqpvewy3", + "target": "nmsqpvewy4" + } + ] +} diff --git a/packages/flow/src/graph/__fixtures__/legacy/pipeline-feature-build.json b/packages/flow/src/graph/__fixtures__/legacy/pipeline-feature-build.json new file mode 100644 index 0000000000..704d5ac15d --- /dev/null +++ b/packages/flow/src/graph/__fixtures__/legacy/pipeline-feature-build.json @@ -0,0 +1,78 @@ +{ + "id": "16b0c27b-0262-4842-9e88-2fc8dda62280", + "name": "feature-build", + "nodes": [ + { + "id": "nmsqpnwo61", + "role": "planner", + "agent": { + "prompt": "You are the planner. Break the task into concrete, ordered steps. Name the files and interfaces involved. Do not write implementation code. End with a numbered plan. Answer in at most 2 short sentences. Do not use tools.", + "tools": { + "read": true, + "grep": true, + "glob": true, + "write": false, + "edit": false, + "bash": false + }, + "model": "opencode/nemotron-3.5-lightning-free" + }, + "position": { + "x": 40, + "y": 40 + } + }, + { + "id": "nmsqpnwo72", + "role": "architect", + "agent": { + "prompt": "You are the architect. Given the plan, decide module boundaries, data shapes, and the public interfaces. Output signatures and file layout. No implementation. Answer in at most 2 short sentences. Do not use tools.", + "tools": { + "read": true, + "grep": true, + "glob": true, + "write": false, + "edit": false, + "bash": false + }, + "model": "opencode/nemotron-3.5-lightning-free" + }, + "position": { + "x": 340, + "y": 40 + } + }, + { + "id": "nmsqpnwo83", + "role": "coder", + "agent": { + "prompt": "You are the coder. Implement exactly what the upstream plan and architecture specify. Keep diffs tight. Answer in at most 2 short sentences. Do not use tools.", + "tools": { + "read": true, + "grep": true, + "glob": true, + "write": true, + "edit": true, + "bash": true + }, + "model": "opencode/nemotron-3.5-lightning-free" + }, + "position": { + "x": 640, + "y": 40 + } + } + ], + "edges": [ + { + "id": "emsqpnwu30", + "source": "nmsqpnwo61", + "target": "nmsqpnwo72" + }, + { + "id": "emsqpnwu31", + "source": "nmsqpnwo72", + "target": "nmsqpnwo83" + } + ] +} diff --git a/packages/flow/src/graph/__fixtures__/legacy/pipeline-omp-native.json b/packages/flow/src/graph/__fixtures__/legacy/pipeline-omp-native.json new file mode 100644 index 0000000000..0b04a5e41c --- /dev/null +++ b/packages/flow/src/graph/__fixtures__/legacy/pipeline-omp-native.json @@ -0,0 +1,21 @@ +{ + "id": "fixture-omp-native", + "name": "already omp", + "mode": "swarm", + "rounds": 2, + "nodes": [ + { + "id": "a", + "role": "researcher", + "agent": { "model": "anthropic/claude-sonnet-4-5", "prompt": "Research." }, + "position": { "x": 0, "y": 0 } + }, + { + "id": "b", + "role": "writer", + "agent": { "prompt": "Write." }, + "position": { "x": 200, "y": 0 } + } + ], + "edges": [{ "id": "e1", "source": "a", "target": "b" }] +} diff --git a/packages/flow/src/graph/__fixtures__/legacy/pipeline-orchestration.json b/packages/flow/src/graph/__fixtures__/legacy/pipeline-orchestration.json new file mode 100644 index 0000000000..f3920138d5 --- /dev/null +++ b/packages/flow/src/graph/__fixtures__/legacy/pipeline-orchestration.json @@ -0,0 +1,34 @@ +{ + "id": "fixture-orchestration", + "name": "orchestrated", + "mode": "orchestration", + "depth": 3, + "dispatches": 4, + "isolate": true, + "gauntlet": { "maxSpend": 10, "maxMinutes": 90, "stall": 4 }, + "verify": { "model": "opencode/big-pickle", "prompt": "Is it done?" }, + "futureSetting": { "enabled": true }, + "nodes": [ + { + "id": "root", + "role": "orchestrator", + "agent": { + "name": "orchestrator", + "model": "opencode/big-pickle", + "prompt": "Coordinate.", + "attachments": [ + { "id": "a1", "name": "notes.txt", "mime": "text/plain", "url": "data:text/plain;base64,aGk=", "size": 2 } + ] + }, + "position": { "x": 0, "y": 0 }, + "collapsed": true + }, + { + "id": "child", + "role": "worker", + "agent": { "model": "openrouter/openrouter/free", "prompt": "Work.", "tools": { "bash": true } }, + "position": { "x": 300, "y": 0 } + } + ], + "edges": [{ "id": "e1", "source": "root", "target": "child", "label": "dispatch" }] +} diff --git a/packages/flow/src/graph/__fixtures__/legacy/run-legacy.json b/packages/flow/src/graph/__fixtures__/legacy/run-legacy.json new file mode 100644 index 0000000000..8314cab5c8 --- /dev/null +++ b/packages/flow/src/graph/__fixtures__/legacy/run-legacy.json @@ -0,0 +1,37 @@ +{ + "id": "run-legacy-1", + "pipeline": "diamond", + "pipelineID": "83a84560-33cf-49a7-a7ac-683b591157cf", + "input": "say hello", + "status": "done", + "started": 1788000000000, + "finished": 1788000060000, + "nodes": [ + { + "id": "nmsqpvewx1", + "role": "planner", + "status": "done", + "sessionID": "ses_2a1f0c3d8ffeAbCdEf", + "model": "opencode/nemotron-3.5-lightning-free", + "agent": "plan", + "prompt": "hello", + "output": "Hello.", + "started": 1788000001000, + "finished": 1788000010000, + "events": [ + { "id": "text:1", "at": 1788000002000, "kind": "text", "depth": 0, "sessionID": "ses_2a1f0c3d8ffeAbCdEf", "title": "text", "body": "Hello." }, + { "id": "tool:2", "at": 1788000003000, "kind": "tool", "depth": 1, "sessionID": "ses_child0001", "parentCallID": "call_1", "title": "read" } + ] + }, + { + "id": "nmsqpvewy2", + "role": "worker-a", + "status": "done", + "sessionID": "ses_3b2e1d4e9ggfBcDeFg", + "output": "Done.", + "reused": true + }, + { "id": "nmsqpvewy3", "role": "worker-b", "status": "skipped" } + ], + "customerNote": "kept" +} diff --git a/packages/flow/src/graph/__fixtures__/legacy/run-malformed-nodes.json b/packages/flow/src/graph/__fixtures__/legacy/run-malformed-nodes.json new file mode 100644 index 0000000000..a5adc209e6 --- /dev/null +++ b/packages/flow/src/graph/__fixtures__/legacy/run-malformed-nodes.json @@ -0,0 +1,15 @@ +{ + "id": "run-malformed", + "pipeline": "p", + "pipelineID": "p1", + "input": "", + "status": "error", + "started": 1, + "nodes": [ + null, + "node", + { "id": "x", "role": "r", "status": "error", "sessionID": 42 }, + { "id": "y", "role": "r", "status": "done", "sessionID": "ses_y", "extraMetric": 1 }, + ["ses_z"] + ] +} diff --git a/packages/flow/src/graph/__fixtures__/legacy/run-omp.json b/packages/flow/src/graph/__fixtures__/legacy/run-omp.json new file mode 100644 index 0000000000..d3e9ca3758 --- /dev/null +++ b/packages/flow/src/graph/__fixtures__/legacy/run-omp.json @@ -0,0 +1,12 @@ +{ + "id": "run-omp-1", + "pipeline": "already omp", + "pipelineID": "fixture-omp-native", + "input": "go", + "status": "done", + "started": 1790000000000, + "nodes": [ + { "id": "a", "role": "researcher", "status": "done", "sessionID": "s_01HABC", "model": "anthropic/claude-sonnet-4-5" }, + { "id": "b", "role": "writer", "status": "idle" } + ] +} diff --git a/packages/flow/src/graph/dispatch.test.ts b/packages/flow/src/graph/dispatch.test.ts index f6523ad61a..87c8105147 100644 --- a/packages/flow/src/graph/dispatch.test.ts +++ b/packages/flow/src/graph/dispatch.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { DISPATCH_TOOL, FENCE, FINISH_TOOL, fromToolCall, parseDispatch } from "./dispatch" +import { DISPATCH_TOOL, FENCE, FINISH_TOOL, fromToolCall, MCP_REACHES_SESSIONS, parseDispatch } from "./dispatch" const children = ["n1", "n2"] const block = (body: string) => "```" + FENCE + "\n" + body + "\n```" @@ -188,6 +188,12 @@ describe("parseDispatch without a fence", () => { }) describe("fromToolCall", () => { + test("the tool channel is the primary one, under the names the host registers", () => { + expect(MCP_REACHES_SESSIONS).toBe(true) + expect(DISPATCH_TOOL).toBe("openflow_dispatch") + expect(FINISH_TOOL).toBe("openflow_finish") + }) + test("reads a dispatch call", () => { expect( fromToolCall(DISPATCH_TOOL, { assignments: [{ card: "n1", task: " read it " }] }, children), @@ -198,6 +204,12 @@ describe("fromToolCall", () => { expect(fromToolCall(FINISH_TOOL, { answer: " done " }, children)).toEqual({ kind: "final", answer: "done" }) }) + test("a host tool call and the fenced block agree on a finish", () => { + expect(fromToolCall(FINISH_TOOL, { answer: "done" }, children)).toEqual( + parseDispatch(block('{ "final": "done" }'), children), + ) + }) + test("a tool that is not ours is not a decision", () => { // The caller keeps looking back through the turn, so "not mine" has to be // distinguishable from "mine and wrong". diff --git a/packages/flow/src/graph/dispatch.ts b/packages/flow/src/graph/dispatch.ts index b48eba1399..8c550dca44 100644 --- a/packages/flow/src/graph/dispatch.ts +++ b/packages/flow/src/graph/dispatch.ts @@ -1,9 +1,10 @@ /** * The orchestrator's control protocol. * - * A card is a plain `opencode` session: it emits one final message and nothing - * else crosses the boundary. So an orchestrator says what it wants to happen - * next by ending that message with a fenced block, and this reads it back. + * A card is a plain Omp session: its final message is what crosses the boundary, + * plus the two host tools it may call. So an orchestrator says what it wants to + * happen next with a tool call, or by ending its message with a fenced block, and + * this reads either back. * * Everything here is deliberately strict. A control instruction that is *nearly* * right — a card id that does not exist, a task that is an empty string, two @@ -18,11 +19,13 @@ import { normalizePath } from "./collisions" export const FENCE = "openflow" /** - * The MCP server that carries the same protocol as tool calls, and the tool - * names opencode registers it under (`_`). + * The two tools an orchestrator card is given, and the names they are called + * under. They are Omp RPC host tools (`CardSpec.dispatch`), registered by the + * host for the card's session, so the names carry no server prefix beyond this + * one — `openflow_dispatch` and `openflow_finish` are what the model sees. * * A tool call is the channel models actually reach for — measured — so it is - * tried first and the fenced block is the fallback. Both ends up in + * tried first and the fenced block is the fallback. Both end up in * `assignmentsFrom` below, so the two channels cannot drift into disagreeing * about what a valid dispatch is. */ @@ -31,33 +34,17 @@ export const DISPATCH_TOOL = `${MCP_SERVER}_dispatch` export const FINISH_TOOL = `${MCP_SERVER}_finish` /** - * Whether an MCP tool can reach a card at all. **It cannot, in this fork.** + * Whether the tool channel is the primary way an orchestrator's decision is + * read. It is: the host answers `openflow_dispatch` / `openflow_finish` as RPC + * host tools for the card's session, and the engine reads the calls back through + * `EngineApi.sessionCalls`. The fenced block stays as the fallback for a card + * whose allowlist leaves the tools out, or a host that does not offer them. * - * OpenFlow drives `client.v2.session.*`, so a card runs through the v2 session - * runner — and that runner has never been wired to MCP. Its own spec comment - * says so, unticked, at `packages/core/src/session/runner/llm.ts`: - * - * - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output - * tool definitions. - * - * MCP tools are converted and registered in exactly one place, the *v1* session - * path (`packages/opencode/src/session/tools.ts`), which OpenFlow does not use. - * The v2 tool registry (`packages/core/src/tool/`) holds read, grep, glob, bash, - * edit, write, question, skill, todowrite, webfetch, websearch and apply-patch, - * and nothing puts MCP beside them — so `openflow_dispatch` comes back as - * `Unknown tool` from `core/src/tool/registry.ts` however the config is written. - * - * `GET /mcp` still answers `connected`, which is what made this look like a - * config bug for so long: the MCP *service* connects the process and holds the - * client perfectly well. It simply never contributes a tool definition to a v2 - * session. - * - * So the channel is parked, not deleted. Everything it needs is built and - * tested; flipping this to `true` is the whole of turning it back on the day - * that checklist line is ticked. Fixing it here is not an option — it would - * mean editing `packages/core`, and this fork modifies no upstream package. + * Kept as one switch because the briefing, the engine's history scan and the + * run bar's install banner all read it; `RunOptions.toolChannel` overrides it + * for a single run. */ -export const MCP_REACHES_SESSIONS = false +export const MCP_REACHES_SESSIONS = true /** * `files` is what the orchestrator says the card will write, if it says. It is diff --git a/packages/flow/src/graph/evals.test.ts b/packages/flow/src/graph/evals.test.ts index fd6adda051..9e416bab89 100644 --- a/packages/flow/src/graph/evals.test.ts +++ b/packages/flow/src/graph/evals.test.ts @@ -38,7 +38,7 @@ describe("the corpus", () => { // shape illegal. It is free, deterministic, and runs in CI — unlike the // half of an eval that spends money. for (const entry of CORPUS) { - const result = preflight(entry.pipeline, { unlockedModels: new Set([EVAL_MODEL]), engineReachable: true }) + const result = preflight(entry.pipeline, { unlockedModels: new Set([EVAL_MODEL]) }) expect({ id: entry.id, blocking: result.blocking.map((problem) => problem.kind) }).toEqual({ id: entry.id, blocking: [], @@ -50,7 +50,6 @@ describe("the corpus", () => { for (const entry of CORPUS) { const raised = preflight(entry.pipeline, { unlockedModels: new Set([EVAL_MODEL]), - engineReachable: true, }).warnings.map((problem) => problem.kind) const undeclared = raised.filter((kind) => !(entry.warns ?? []).includes(kind)) // Named rather than counted: a new warning appearing on a shipped diff --git a/packages/flow/src/graph/migrate.test.ts b/packages/flow/src/graph/migrate.test.ts new file mode 100644 index 0000000000..dc2cafcc35 --- /dev/null +++ b/packages/flow/src/graph/migrate.test.ts @@ -0,0 +1,349 @@ +import { describe, expect, test } from "bun:test" +import { readdirSync, readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { isLegacySession, legacyModelProblems, migratePipeline, migrateRunLog } from "./migrate" +import { isPipeline } from "./pipeline-io" + +const dir = fileURLToPath(new URL("./__fixtures__/legacy/", import.meta.url)) +const files = readdirSync(dir).filter((file) => file.endsWith(".json")) + +function fixture(name: string): unknown { + return JSON.parse(readFileSync(`${dir}${name}.json`, "utf8")) +} + +function kinds(notes: { kind: string }[]) { + return notes.map((note) => note.kind) +} + +function nested(depth: number): unknown { + let value: unknown = { leaf: true } + for (let i = 0; i < depth; i++) value = { inner: value, list: [value] } + return value +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function rec(value: unknown): Record { + if (!isObject(value)) throw new Error("expected an object") + return value +} + +function list(value: unknown): unknown[] { + if (!Array.isArray(value)) throw new Error("expected an array") + return value +} + +/** The `agent` object of every card in a migrated pipeline. */ +function agents(pipeline: unknown) { + return list(rec(pipeline).nodes).map((node) => rec(rec(node).agent)) +} + +describe("fixtures", () => { + test("every fixture is covered by a migrator below", () => { + expect(files.filter((file) => !file.startsWith("pipeline-") && !file.startsWith("run-"))).toEqual([]) + }) + + for (const file of files.filter((name) => name.startsWith("pipeline-"))) { + test(`${file}: migrates to a valid pipeline, without mutating the input, idempotently`, () => { + const raw = JSON.parse(readFileSync(`${dir}${file}`, "utf8")) + const before = JSON.stringify(raw) + const first = migratePipeline(raw) + expect(JSON.stringify(raw)).toBe(before) + expect(isPipeline(first.pipeline)).toBe(true) + const second = migratePipeline(first.pipeline) + expect(second.pipeline).toEqual(first.pipeline) + expect(second.notes).toEqual([]) + }) + } + + for (const file of files.filter((name) => name.startsWith("run-"))) { + test(`${file}: migrates without mutating the input, idempotently`, () => { + const raw = JSON.parse(readFileSync(`${dir}${file}`, "utf8")) + const before = JSON.stringify(raw) + const first = migrateRunLog(raw) + expect(JSON.stringify(raw)).toBe(before) + const second = migrateRunLog(first.run) + expect(second.run).toEqual(first.run) + expect(second.notes).toEqual([]) + }) + } +}) + +describe("migratePipeline", () => { + test("real saved pipeline on opencode zen models: ids kept verbatim, one note per card", () => { + const { pipeline, notes } = migratePipeline(fixture("pipeline-diamond")) + expect(agents(pipeline).map((agent) => agent.model)).toEqual(Array(4).fill("opencode/nemotron-3.5-lightning-free")) + expect(kinds(notes)).toEqual(Array(4).fill("legacy-model")) + expect(notes.map((note) => note.nodeId)).toEqual(["nmsqpvewx1", "nmsqpvewy2", "nmsqpvewy3", "nmsqpvewy4"]) + expect(notes[0].message).toBe( + "model opencode/nemotron-3.5-lightning-free is an OpenCode zen model: pick an Omp model", + ) + }) + + test("the other real pipeline migrates too, and reports only models", () => { + const { notes } = migratePipeline(fixture("pipeline-feature-build")) + expect(kinds(notes).every((kind) => kind === "legacy-model")).toBe(true) + expect(notes.length).toBeGreaterThan(0) + }) + + test("FlowAgent.name is dropped with a legacy-agent note; an empty name is dropped silently", () => { + const { pipeline, notes } = migratePipeline(fixture("pipeline-agent-name")) + expect(agents(pipeline)[0]).toEqual({ + model: "anthropic/claude-sonnet-4-5", + prompt: "Build it.", + tools: { read: true, write: true }, + mcp: ["github"], + }) + expect("name" in agents(pipeline)[1]).toBe(false) + expect(notes).toHaveLength(1) + expect(notes[0].kind).toBe("legacy-agent") + expect(notes[0].nodeId).toBe("n1") + expect(notes[0].message).toContain("agent 'build'") + expect(notes[0].message).toContain("OpenCode agent") + }) + + test("a pipeline already on Omp models is returned unchanged with no notes", () => { + const raw = fixture("pipeline-omp-native") + const result = migratePipeline(raw) + expect(result.notes).toEqual([]) + expect(result.pipeline).toEqual(raw) + expect(isPipeline(result.pipeline)).toBe(true) + }) + + test("unknown fields at every level are preserved and named, never valued", () => { + const { pipeline, notes } = migratePipeline(fixture("pipeline-orchestration")) + const doc = rec(pipeline) + expect(doc.futureSetting).toEqual({ enabled: true }) + expect(rec(list(doc.nodes)[0]).collapsed).toBe(true) + expect(rec(list(doc.edges)[0]).label).toBe("dispatch") + const unknown = notes.filter((note) => note.kind === "unknown-field") + expect(unknown.map((note) => note.message)).toEqual([ + "the pipeline has fields this version does not know (futureSetting): kept as written", + "card 'orchestrator' has fields this version does not know (collapsed): kept as written", + "edge 'e1' has fields this version does not know (label): kept as written", + ]) + expect(unknown[1].nodeId).toBe("root") + // The orchestrator on a zen model also named an opencode agent. + expect(kinds(notes)).toContain("legacy-agent") + expect(kinds(notes)).toContain("legacy-model") + expect(doc.mode).toBe("orchestration") + expect(doc.gauntlet).toEqual({ maxSpend: 10, maxMinutes: 90, stall: 4 }) + }) + + test("models that cannot be provider/id are kept as written and noted", () => { + const { pipeline, notes } = migratePipeline(fixture("pipeline-bad-models")) + expect(agents(pipeline).map((agent) => agent.model)).toEqual([ + "gpt-4", + "anthropic/", + "/claude", + 7, + "OpenCode/Big-Pickle", + "", + "openrouter/openrouter/free", + ]) + expect(notes.map((note) => note.nodeId)).toEqual(["n1", "n2", "n3", "n4", "n5"]) + expect(notes.map((note) => note.message)).toEqual([ + 'model gpt-4 is not a "provider/id" pair: pick an Omp model', + 'model anthropic/ is not a "provider/id" pair: pick an Omp model', + 'model /claude is not a "provider/id" pair: pick an Omp model', + 'model is number, not a "provider/id" string: pick an Omp model', + "model OpenCode/Big-Pickle is an OpenCode zen model: pick an Omp model", + ]) + }) + + test("a nested provider slash is a valid Omp model", () => { + expect(migratePipeline({ id: "p", name: "p", nodes: [], edges: [] }).notes).toEqual([]) + const { notes } = migratePipeline(fixture("pipeline-bad-models")) + expect(notes.some((note) => note.message.includes("openrouter/openrouter/free"))).toBe(false) + }) + + test("a pipeline with an unsupported mode is passed through for isPipeline to refuse", () => { + const raw = { id: "p", name: "p", mode: "turbo", nodes: [], edges: [] } + const { pipeline } = migratePipeline(raw) + expect(pipeline).toEqual(raw) + expect(isPipeline(pipeline)).toBe(false) + }) +}) + +describe("legacyModelProblems", () => { + test("lists each card that still cannot run, by role, before and after migration", () => { + const raw = fixture("pipeline-bad-models") + const expected = [ + "Card 'no-slash': model gpt-4 is not a \"provider/id\" pair: pick an Omp model", + "Card 'no-id': model anthropic/ is not a \"provider/id\" pair: pick an Omp model", + "Card 'no-provider': model /claude is not a \"provider/id\" pair: pick an Omp model", + "Card 'not-a-string': model is number, not a \"provider/id\" string: pick an Omp model", + "Card 'mixed-case': model OpenCode/Big-Pickle is an OpenCode zen model: pick an Omp model", + ] + expect(legacyModelProblems(raw)).toEqual(expected) + expect(legacyModelProblems(migratePipeline(raw).pipeline)).toEqual(expected) + }) + + test("a migrated zen pipeline keeps reporting, though migrate no longer repeats the notes", () => { + const { pipeline } = migratePipeline(fixture("pipeline-diamond")) + expect(legacyModelProblems(pipeline)).toHaveLength(4) + expect(legacyModelProblems(pipeline)[0]).toBe( + "Card 'planner': model opencode/nemotron-3.5-lightning-free is an OpenCode zen model: pick an Omp model", + ) + }) + + test("is empty for an Omp pipeline and for shapes it cannot read", () => { + expect(legacyModelProblems(fixture("pipeline-omp-native"))).toEqual([]) + for (const value of [null, undefined, 3, "x", [], {}, { nodes: 3 }, { nodes: [null, 1, { agent: 1 }] }]) { + expect(legacyModelProblems(value)).toEqual([]) + } + }) +}) + +describe("isLegacySession", () => { + test("recognises opencode's ses_ prefix and not the host's s_ prefix", () => { + expect(isLegacySession("ses_2a1f0c3d8ffeAbCdEf")).toBe(true) + expect(isLegacySession("s_01HABC")).toBe(false) + expect(isLegacySession("")).toBe(false) + expect(isLegacySession("xses_1")).toBe(false) + }) +}) + +describe("migrateRunLog", () => { + test("keeps opencode sessionIDs and says they cannot be resumed", () => { + const raw = fixture("run-legacy") + const { run, notes } = migrateRunLog(raw) + const sessions = (value: unknown) => list(rec(value).nodes).map((node) => rec(node).sessionID) + expect(sessions(run)).toEqual(sessions(raw)) + expect(rec(run).customerNote).toBe("kept") + expect(notes).toEqual([ + { kind: "unknown-field", message: "the run log has fields this version does not know (customerNote): kept as written" }, + { + kind: "legacy-session", + nodeId: "nmsqpvewx1", + message: "session ses_2a1f0c3d8ffeAbCdEf is an OpenCode session: it can be read but cannot be resumed", + }, + { + kind: "legacy-session", + nodeId: "nmsqpvewy2", + message: "session ses_3b2e1d4e9ggfBcDeFg is an OpenCode session: it can be read but cannot be resumed", + }, + ]) + }) + + test("a run recorded by the Omp engine has no notes and is unchanged", () => { + const raw = fixture("run-omp") + const result = migrateRunLog(raw) + expect(result.notes).toEqual([]) + expect(result.run).toEqual(raw) + }) + + test("malformed nodes never throw; legacy sessions among them are still found", () => { + const { run, notes } = migrateRunLog(fixture("run-malformed-nodes")) + expect(list(rec(run).nodes)).toHaveLength(5) + expect(notes.map((note) => note.kind)).toEqual(["unknown-field", "legacy-session"]) + expect(notes[0].nodeId).toBe("y") + expect(notes[1].nodeId).toBe("y") + }) +}) + +describe("malformed input", () => { + const scalars: unknown[] = [null, undefined, 0, 1.5, true, "", "{}", "ses_1", [], [1, 2], [{ id: "x" }]] + + test("non-objects come back as given with a note, from both migrators", () => { + for (const value of scalars) { + const pipeline = migratePipeline(value) + expect(pipeline.pipeline).toBe(value) + expect(pipeline.notes).toEqual([ + { kind: "unknown-field", message: "the pipeline is not a JSON object, so it was left untouched" }, + ]) + const run = migrateRunLog(value) + expect(run.run).toBe(value) + expect(run.notes).toEqual([ + { kind: "unknown-field", message: "the run log is not a JSON object, so it was left untouched" }, + ]) + expect(isPipeline(pipeline.pipeline)).toBe(false) + } + }) + + test("deep nesting does not overflow the stack", () => { + const deep = nested(50_000) + const asPipeline = { id: "p", name: "p", nodes: [{ id: "n", role: "r", agent: { prompt: "", deep }, position: { x: 0, y: 0 } }], edges: [], deep } + const result = migratePipeline(asPipeline) + expect(result.notes.map((note) => note.kind)).toEqual(["unknown-field", "unknown-field"]) + expect(rec(result.pipeline).deep).toBe(deep) + const run = migrateRunLog({ id: "r", nodes: [{ id: "n", sessionID: "ses_1", deep }], deep }) + expect(run.notes.map((note) => note.kind)).toEqual(["unknown-field", "unknown-field", "legacy-session"]) + expect(migratePipeline(deep).notes).toHaveLength(1) + }) + + test("malformed parts of an otherwise real pipeline are passed through for isPipeline to refuse", () => { + const graph = fixture("pipeline-agent-name") as Record + const broken = { ...graph, nodes: [null, "x", { id: 3, agent: [] }, { id: "ok", role: "r", agent: null }], edges: [7, null, []] } + const result = migratePipeline(broken) + expect(result.notes.every((note) => note.kind === "unknown-field")).toBe(true) + expect(isPipeline(result.pipeline)).toBe(false) + expect(legacyModelProblems(result.pipeline)).toEqual([]) + }) + + test("a stray __proto__ key from JSON.parse is kept as data and does not pollute", () => { + const raw = JSON.parse('{"id":"p","name":"p","nodes":[],"edges":[],"__proto__":{"polluted":true}}') + const { pipeline } = migratePipeline(raw) + expect(({} as Record).polluted).toBeUndefined() + expect(Object.keys(pipeline as object)).toContain("__proto__") + }) + + test("fuzz shapes: never throws, always idempotent once stamped", () => { + const leaves: unknown[] = [null, 0, "", "opencode/x", "ses_1", [], {}, true, nested(3)] + const keys = ["id", "name", "nodes", "edges", "mode", "agent", "model", "sessionID", "role", "position", "prompt", "extra", "migratedFromOpencode"] + let seed = 7 + const next = (n: number) => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff + return seed % n + } + const make = (depth: number): unknown => { + if (depth === 0 || next(4) === 0) return leaves[next(leaves.length)] + if (next(3) === 0) return Array.from({ length: next(4) }, () => make(depth - 1)) + const out: Record = {} + for (let i = next(6); i > 0; i--) out[keys[next(keys.length)]] = make(depth - 1) + return out + } + for (let i = 0; i < 2000; i++) { + const value = make(5) + const pipeline = migratePipeline(value) + const again = migratePipeline(pipeline.pipeline) + expect(again.pipeline).toEqual(pipeline.pipeline) + const run = migrateRunLog(value) + expect(migrateRunLog(run.run).run).toEqual(run.run) + expect(Array.isArray(legacyModelProblems(value))).toBe(true) + } + }) +}) + +describe("idempotence", () => { + test("a migrated document migrates to itself with no notes, including after a JSON round trip", () => { + for (const name of ["pipeline-diamond", "pipeline-agent-name", "pipeline-orchestration", "pipeline-bad-models"]) { + const first = migratePipeline(fixture(name)) + expect(first.notes.length).toBeGreaterThan(0) + const reloaded = JSON.parse(JSON.stringify(first.pipeline)) + const second = migratePipeline(reloaded) + expect(second.notes).toEqual([]) + expect(second.pipeline).toEqual(reloaded) + } + const run = migrateRunLog(fixture("run-legacy")) + const reloaded = JSON.parse(JSON.stringify(run.run)) + expect(migrateRunLog(reloaded).notes).toEqual([]) + expect(migrateRunLog(reloaded).run).toEqual(reloaded) + }) + + test("a stamped document still reports an opencode agent name it somehow regained", () => { + const first = migratePipeline(fixture("pipeline-diamond")).pipeline + agents(first)[0].name = "build" + const { notes } = migratePipeline(first) + expect(kinds(notes)).toEqual(["legacy-agent"]) + }) + + test("the stamp is only added when there was something to report", () => { + const clean = migratePipeline(fixture("pipeline-omp-native")).pipeline + expect("migratedFromOpencode" in rec(clean)).toBe(false) + const dirty = migratePipeline(fixture("pipeline-diamond")).pipeline + expect(rec(dirty).migratedFromOpencode).toBe(1) + }) +}) diff --git a/packages/flow/src/graph/migrate.ts b/packages/flow/src/graph/migrate.ts new file mode 100644 index 0000000000..4f414c1447 --- /dev/null +++ b/packages/flow/src/graph/migrate.ts @@ -0,0 +1,232 @@ +/** + * Loading documents saved by the OpenCode build into the Omp build. + * + * Both functions are pure and total: any JSON value goes in, a document and a + * list of notes comes out, and nothing here throws. They never decide what the + * user meant. A model the Omp catalog cannot be shown to contain stays exactly + * as written and is reported, because guessing a replacement would silently run + * a card on a different model than the one the file names. + * + * Idempotence: the first migration that has anything to report stamps the + * document with `MIGRATED`. A stamped document is the user's acknowledged state, + * so the notes that describe data we *kept* (model ids, session ids, unknown + * fields) are not repeated; `legacyModelProblems` is what keeps reporting a + * model that still cannot run. Nothing is walked recursively, so nesting depth + * in the input cannot exhaust the stack. + */ + +export type MigrationNote = { + kind: "legacy-model" | "legacy-agent" | "legacy-session" | "unknown-field" + nodeId?: string + message: string +} + +/** Present (as `1`) on a document that has already been through a migration that reported something. */ +const MIGRATED = "migratedFromOpencode" + +const PIPELINE_KEYS = [ + "id", + "name", + "nodes", + "edges", + "mode", + "rounds", + "depth", + "dispatches", + "gauntlet", + "isolate", + "verify", + MIGRATED, +] +const NODE_KEYS = ["id", "role", "agent", "position"] +const AGENT_KEYS = ["model", "prompt", "tools", "mcp", "attachments"] +const EDGE_KEYS = ["id", "source", "target"] + +const RUN_KEYS = [ + "id", + "pipeline", + "pipelineID", + "input", + "attachments", + "status", + "started", + "finished", + "nodes", + "usage", + "verdict", + "rounds", + "best", + MIGRATED, +] +const RUN_NODE_KEYS = [ + "id", + "role", + "toolFailures", + "ranShell", + "status", + "sessionID", + "permissions", + "questions", + "attachments", + "model", + "agent", + "prompt", + "output", + "error", + "started", + "finished", + "reused", + "usage", + "diff", + "steps", + "events", +] + +/** OpenCode mints `ses_…`; the host mints `s_…`. The two never collide. */ +export function isLegacySession(id: string): boolean { + return id.startsWith("ses_") +} + +export function migratePipeline(raw: unknown): { pipeline: unknown; notes: MigrationNote[] } { + const notes: MigrationNote[] = [] + if (!isRecord(raw)) { + notes.push({ kind: "unknown-field", message: "the pipeline is not a JSON object, so it was left untouched" }) + return { pipeline: raw, notes } + } + + const stamped = raw[MIGRATED] === 1 + if (!stamped) noteExtras(raw, PIPELINE_KEYS, "the pipeline", undefined, notes) + + const next: Record = { ...raw } + if (Array.isArray(raw.nodes)) next.nodes = raw.nodes.map((node) => migrateNode(node, stamped, notes)) + if (Array.isArray(raw.edges) && !stamped) { + for (const edge of raw.edges) { + if (isRecord(edge)) noteExtras(edge, EDGE_KEYS, `edge ${label(edge)}`, undefined, notes) + } + } + + return { pipeline: stamp(next, stamped, notes), notes } +} + +export function migrateRunLog(raw: unknown): { run: unknown; notes: MigrationNote[] } { + const notes: MigrationNote[] = [] + if (!isRecord(raw)) { + notes.push({ kind: "unknown-field", message: "the run log is not a JSON object, so it was left untouched" }) + return { run: raw, notes } + } + + const stamped = raw[MIGRATED] === 1 + if (!stamped) noteExtras(raw, RUN_KEYS, "the run log", undefined, notes) + + if (!stamped && Array.isArray(raw.nodes)) { + for (const node of raw.nodes) { + if (!isRecord(node)) continue + const nodeId = typeof node.id === "string" ? node.id : undefined + noteExtras(node, RUN_NODE_KEYS, `card ${label(node)}`, nodeId, notes) + if (typeof node.sessionID !== "string" || !isLegacySession(node.sessionID)) continue + notes.push({ + kind: "legacy-session", + nodeId, + message: `session ${node.sessionID} is an OpenCode session: it can be read but cannot be resumed`, + }) + } + } + + return { run: stamp({ ...raw }, stamped, notes), notes } +} + +/** One line per card whose model the Omp engine cannot be given as written. For preflight. */ +export function legacyModelProblems(pipeline: unknown): string[] { + if (!isRecord(pipeline) || !Array.isArray(pipeline.nodes)) return [] + const problems: string[] = [] + for (const node of pipeline.nodes) { + if (!isRecord(node) || !isRecord(node.agent)) continue + const problem = modelProblem(node.agent.model) + if (problem) problems.push(`Card ${label(node)}: ${problem}`) + } + return problems +} + +function migrateNode(node: unknown, stamped: boolean, notes: MigrationNote[]): unknown { + if (!isRecord(node)) return node + const nodeId = typeof node.id === "string" ? node.id : undefined + if (!stamped) noteExtras(node, NODE_KEYS, `card ${label(node)}`, nodeId, notes) + if (!isRecord(node.agent)) return node + return { ...node, agent: migrateAgent(node.agent, nodeId, label(node), stamped, notes) } +} + +function migrateAgent( + agent: Record, + nodeId: string | undefined, + card: string, + stamped: boolean, + notes: MigrationNote[], +): Record { + if (!stamped) { + noteExtras(agent, AGENT_KEYS, `the agent of card ${card}`, nodeId, notes, "name") + const problem = modelProblem(agent.model) + if (problem) notes.push({ kind: "legacy-model", nodeId, message: problem }) + } + + if (!("name" in agent)) return agent + const next = { ...agent } + delete next.name + // An empty or null name meant "server default": nothing was chosen, so + // nothing is lost by dropping it. + if (agent.name === undefined || agent.name === null || agent.name === "") return next + const named = typeof agent.name === "string" ? `agent '${agent.name}'` : "an agent" + notes.push({ + kind: "legacy-agent", + nodeId, + message: `card ${card} named ${named}, which is an OpenCode agent: Omp has no agents by name, so the name was dropped and the card runs on its own prompt`, + }) + return next +} + +/** Why a model string cannot be handed to Omp as written, or `undefined` when it can (or is blank, which preflight owns). */ +function modelProblem(model: unknown): string | undefined { + if (model === undefined || model === null || model === "") return undefined + if (typeof model !== "string") return `model is ${typeof model}, not a "provider/id" string: pick an Omp model` + const slash = model.indexOf("/") + if (slash <= 0 || slash === model.length - 1) { + return `model ${model} is not a "provider/id" pair: pick an Omp model` + } + if (model.slice(0, slash).toLowerCase() === "opencode") { + return `model ${model} is an OpenCode zen model: pick an Omp model` + } + return undefined +} + +/** + * Names only, never values: an unknown field is as likely to hold a credential + * as anything else, and a note ends up in the UI. + */ +function noteExtras( + value: Record, + known: string[], + where: string, + nodeId: string | undefined, + notes: MigrationNote[], + alsoKnown?: string, +) { + const extras = Object.keys(value).filter((key) => !known.includes(key) && key !== alsoKnown) + if (!extras.length) return + const shown = extras.slice(0, 5).join(", ") + (extras.length > 5 ? `, and ${extras.length - 5} more` : "") + notes.push({ kind: "unknown-field", nodeId, message: `${where} has fields this version does not know (${shown}): kept as written` }) +} + +function stamp(document: Record, stamped: boolean, notes: MigrationNote[]) { + if (stamped || !notes.length) return document + document[MIGRATED] = 1 + return document +} + +function label(value: Record) { + if (typeof value.role === "string" && value.role) return `'${value.role}'` + if (typeof value.id === "string") return `'${value.id}'` + return "(unnamed)" +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/flow/src/graph/orchestration.ts b/packages/flow/src/graph/orchestration.ts index cc6bd8ab6a..eb29da25e3 100644 --- a/packages/flow/src/graph/orchestration.ts +++ b/packages/flow/src/graph/orchestration.ts @@ -6,7 +6,7 @@ export const ORCHESTRATOR_ROLE = "orchestrator" /** * The role a card must carry to judge a gauntlet's work. * - * It is opencode's own `reviewer`, not a new role: reviewing against a standard + * It is the stock `reviewer` role, not a new one: reviewing against a standard * is what that card already is, and a gauntlet only changes what it is pointed * at and how often. Read off the role text the same way a swarm reads its * synthesizer, so designating a critic is renaming a card rather than setting a diff --git a/packages/flow/src/graph/prompt.test.ts b/packages/flow/src/graph/prompt.test.ts index bcd63fa9eb..8221f8886d 100644 --- a/packages/flow/src/graph/prompt.test.ts +++ b/packages/flow/src/graph/prompt.test.ts @@ -1,19 +1,32 @@ import { describe, expect, test } from "bun:test" import { DISPATCH_TOOL, FINISH_TOOL, MCP_REACHES_SESSIONS } from "./dispatch" +import { UPLIFTED_ROOT_TAGS } from "../../shared/uplift" import { FAIL, PASS } from "./verdict" import { + alreadyUplifted, buildPrompt, + buildSections, + CONTEXT_HEADING, criticPrompt, forceFinalPrompt, dispatchResultPrompt, orchestratorPrompt, pipelineBriefing, + plainPrompt, reassignPrompt, + stripRaw, subOrchestratorPrompt, + subOrchestratorSections, subagentPrompt, + subagentSections, swarmBriefing, swarmPrompt, + swarmSections, synthesisPrompt, + synthesisSections, + taskOf, + upliftedPrompt, + verifyPrompt, } from "./prompt" import { nodeMap, pipeline } from "./test-support" import type { FlowNode, Pipeline } from "./types" @@ -374,28 +387,137 @@ describe("no card is ever asked to ask", () => { }) }) -describe("the parked tool channel", () => { - // Naming a tool the card cannot call costs a whole turn: it calls, gets - // `Unknown tool`, and only then writes the block. Measured on every run. - test("the briefing teaches only the channel the card actually has", () => { +describe("the tool channel", () => { + test("the briefing teaches the tools first and keeps the fenced block as the fallback", () => { const graph: Pipeline = { ...pipeline("boss->a"), mode: "orchestration" } const text = orchestratorPrompt(graph, graph.nodes[0], "do it") - expect(MCP_REACHES_SESSIONS).toBe(false) - expect(text).not.toContain(DISPATCH_TOOL) - expect(text).not.toContain(FINISH_TOOL) - expect(text).toContain("there is no dispatch tool") - expect(text).toContain("```openflow") + expect(MCP_REACHES_SESSIONS).toBe(true) + expect(text).toContain(DISPATCH_TOOL) + expect(text).toContain(FINISH_TOOL) + expect(text).not.toContain("there is no dispatch tool") + expect(text.indexOf(DISPATCH_TOOL)).toBeLessThan(text.indexOf("```openflow")) expect(text).toContain('{ "final": "the answer to the task" }') }) - test("the forced answer names no tool either", () => { + test("the forced answer names the finish tool and still offers the block", () => { const text = forceFinalPrompt("Your dispatch budget is spent.") - expect(text).not.toContain(FINISH_TOOL) + expect(text).toContain(FINISH_TOOL) expect(text).toContain("fenced block") }) }) +describe("memory is no longer denied", () => { + const orchestration: Pipeline = { ...pipeline("boss->a"), mode: "orchestration" } + const texts = [ + pipelineBriefing(graph, graph.nodes[0]), + swarmBriefing({ ...pipeline("a", "b", "verdict"), mode: "swarm" }, pipeline("a").nodes[0]), + orchestratorPrompt(orchestration, orchestration.nodes[0], "do it"), + subagentPrompt(orchestration, orchestration.nodes[1], orchestration.nodes[0], "task", "input"), + ] + + test("no briefing tells a card it shares no memory", () => { + for (const text of texts) expect(text).not.toMatch(/share no memory/) + }) + + test("every briefing says memory may be appended as untrusted evidence", () => { + for (const text of texts) expect(text).toMatch(/project memory/i) + for (const text of texts) expect(text).toContain("untrusted evidence") + }) + + test("no prompt names opencode", () => { + const critic = criticPrompt(orchestration, orchestration.nodes[1], orchestration.nodes[0], "task", "input") + const verify = verifyPrompt(graph, graph.nodes[0], "result") + for (const text of [...texts, critic, verify]) expect(text).not.toContain("opencode") + }) +}) + +describe("prompt sections", () => { + const input = "ship the thing" + + test("the plain prompt is the sections in order, unchanged by the split", () => { + const sections = buildSections(graph, nodes.get("coder")!, ["planner"], outputs, input) + expect(plainPrompt(sections)).toBe(buildPrompt(graph, nodes.get("coder")!, ["planner"], outputs, input)) + expect(sections.filter((section) => section.own).map((section) => section.text)).toEqual([`# Task\n\n${input}`]) + }) + + test("a card's own task is its role instructions and its task, never another card's output", () => { + const coder = node("coder", "Write the code.") + const sections = buildSections(graph, coder, ["planner"], outputs, input) + expect(taskOf(sections)).toBe(`Write the code.\n\n# Task\n\n${input}`) + expect(taskOf(sections)).not.toContain("planner") + }) + + test("a sub-orchestrator's and a subagent's assignment are task text; their briefings are not", () => { + const tree: Pipeline = { ...pipeline("boss->mid", "mid->leaf"), mode: "orchestration" } + const mid = subOrchestratorSections(tree, tree.nodes[1], tree.nodes[0], "build the middle", input) + expect(taskOf(mid)).toContain("build the middle") + expect(taskOf(mid)).not.toContain("# OpenFlow") + const leaf = subagentSections(tree, tree.nodes[2], tree.nodes[1], "write the leaf", input) + expect(taskOf(leaf)).toBe(`# What the run is for\n\n${input}\n\n# Your assignment\n\nwrite the leaf`) + }) + + test("round 1 of a swarm and a synthesis are task turns with their own split", () => { + const mesh: Pipeline = { ...pipeline("a", "b", "verdict"), mode: "swarm" } + expect(plainPrompt(swarmSections(mesh, mesh.nodes[0], input))).toBe(swarmPrompt(mesh, mesh.nodes[0], 1, new Map(), input)) + expect(taskOf(swarmSections(mesh, mesh.nodes[0], input))).toBe(`# Task\n\n${input}`) + const finals = new Map([["a", "a said"]]) + const synth = synthesisSections(mesh, mesh.nodes[2], finals, input) + expect(plainPrompt(synth)).toBe(synthesisPrompt(mesh, mesh.nodes[2], finals, input)) + expect(taskOf(synth)).toBe(`# Task\n\n${input}`) + }) + + test("an uplifted prompt keeps the spec first and every other section verbatim under one heading", () => { + const coder = node("coder", "Write the code.") + const sections = buildSections(graph, coder, ["planner"], outputs, input) + const text = upliftedPrompt(sections, "spec\n", ["a note"]) + + expect(text.startsWith("spec\n\n" + CONTEXT_HEADING)).toBe(true) + expect(text).toContain(pipelineBriefing(graph, coder)) + expect(text).toContain("## planner (planner)") + expect(text.endsWith("a note")).toBe(true) + // The original task text is not repeated: the spec replaced it. + expect(text).not.toContain("# Task") + expect(text).not.toContain("Write the code.") + }) + + test("with nothing to append the spec goes alone", () => { + expect(upliftedPrompt([{ text: "own", own: true }], "")).toBe("") + }) + + test("text already carrying a spec root tag is recognised, behind a heading or not", () => { + for (const tag of UPLIFTED_ROOT_TAGS) { + expect(alreadyUplifted([{ text: `<${tag}>x`, own: true }])).toBe(true) + expect(alreadyUplifted([{ text: `# Task\n\n <${tag} id="1">`, own: true }])).toBe(true) + } + expect(alreadyUplifted([{ text: "# Task\n\nplease this", own: true }])).toBe(false) + expect(alreadyUplifted([{ text: "", own: true }])).toBe(false) + // Context is another card's text; a spec there says nothing about this task. + expect(alreadyUplifted([{ text: "x" }])).toBe(false) + }) + + test("a leading raw: opts out and is stripped, wherever the task sits", () => { + const coder = node("coder", "") + const stripped = stripRaw(buildSections(graph, coder, [], outputs, "raw: just do it")) + expect(stripped.raw).toBe(true) + expect(taskOf(stripped.sections)).toBe("# Task\n\njust do it") + + const bare = stripRaw([{ text: " RAW: keep going", own: true }]) + expect(bare.raw).toBe(true) + expect(bare.sections[0].text).toBe(" keep going") + }) + + test("raw: in the middle of a task, or in context, is just text", () => { + const sections = [ + { text: "# Task\n\nexplain raw: strings", own: true as const }, + { text: "raw: in context" }, + ] + const result = stripRaw(sections) + expect(result.raw).toBe(false) + expect(result.sections).toEqual(sections) + }) +}) + describe("gauntlet prompts", () => { /** An orchestrator with one builder and one critic, running as a gauntlet. */ function gauntlet(bar = "the reference build, screenshot by screenshot"): Pipeline { diff --git a/packages/flow/src/graph/prompt.ts b/packages/flow/src/graph/prompt.ts index c38c5199c5..12002667a5 100644 --- a/packages/flow/src/graph/prompt.ts +++ b/packages/flow/src/graph/prompt.ts @@ -1,3 +1,4 @@ +import { UPLIFTED_ROOT_TAGS } from "../../shared/uplift" import { DISPATCH_TOOL, FENCE, FINISH_TOOL, MCP_REACHES_SESSIONS } from "./dispatch" import { isCritic, orchestrationShape, subagentsOf } from "./orchestration" import { swarmShape } from "./swarm" @@ -24,8 +25,66 @@ function withheld(skipped: Attachment[]) { return `# Attachments you cannot read\n\nThe run carries files this model has no input modality for, so they were withheld:\n\n${list}\n\nContinue with the task; do not claim to have seen them.` } +/** + * One block of a card's first prompt. `own` marks the card's own task — its role + * instructions and the task text it was given — which is the only part Prompt + * Uplift may rewrite. Everything else (the briefing, withheld attachments, + * upstream output, the debate so far) is context the engine appends verbatim. + */ +export type PromptSection = { text: string; own?: true } + +/** The sections exactly as they were always sent: in order, one blank line apart. */ +export function plainPrompt(sections: PromptSection[]) { + return sections.map((section) => section.text).join("\n\n") +} + +/** What Prompt Uplift is given: the card's own task text and nothing that came from another card. */ +export function taskOf(sections: PromptSection[]) { + return sections + .filter((section) => section.own) + .map((section) => section.text) + .join("\n\n") +} + +export const CONTEXT_HEADING = "# Context from earlier cards" + +/** + * The prompt a card gets when its task was uplifted: the spec first, then every + * non-task section verbatim under one heading, so a spec that rewrote the task + * cannot be mistaken for having absorbed the upstream output beneath it. + * `notes` are the engine's own per-turn additions (image-blind, scratch). + */ +export function upliftedPrompt(sections: PromptSection[], uplifted: string, notes: string[] = []) { + const context = [...sections.filter((section) => !section.own).map((section) => section.text), ...notes] + return [uplifted.trim(), ...(context.length ? [`${CONTEXT_HEADING}\n\n${context.join("\n\n")}`] : [])].join("\n\n") +} + +const UPLIFTED = new RegExp(`^\\s*(?:#{1,6}[^\\n]*\\n+)?\\s*<(?:${UPLIFTED_ROOT_TAGS.join("|")})[\\s>/]`) + +/** Whether a task already starts with one of ultrathink's spec root tags (after an optional heading). */ +export function alreadyUplifted(sections: PromptSection[]) { + return sections.some((section) => section.own && UPLIFTED.test(section.text)) +} + +const RAW = /^(\s*(?:#{1,6}[^\n]*\n+)?\s*)raw:[ \t]*/i + +/** + * `raw:` at the start of a task is the user's opt-out from uplift: send it as + * written. The marker is syntax, not text, so it is stripped from what is sent + * whether or not uplift was going to run. + */ +export function stripRaw(sections: PromptSection[]) { + let raw = false + const next = sections.map((section) => { + if (!section.own || !RAW.test(section.text)) return section + raw = true + return { ...section, text: section.text.replace(RAW, "$1") } + }) + return { sections: next, raw } +} + /** Node prompt = pipeline briefing + role instructions + run task + serialized upstream outputs. */ -export function buildPrompt( +export function buildSections( pipeline: Pipeline, node: FlowNode, sources: string[], @@ -35,11 +94,11 @@ export function buildPrompt( skipped: Attachment[] = [], ) { const nodes = new Map(pipeline.nodes.map((entry) => [entry.id, entry] as const)) - const sections: string[] = [pipelineBriefing(pipeline, node)] - if (node.agent.prompt.trim()) sections.push(node.agent.prompt.trim()) - if (input.trim()) sections.push(`# Task\n\n${input.trim()}`) + const sections: PromptSection[] = [{ text: pipelineBriefing(pipeline, node) }] + if (node.agent.prompt.trim()) sections.push({ text: node.agent.prompt.trim(), own: true }) + if (input.trim()) sections.push({ text: `# Task\n\n${input.trim()}`, own: true }) const unreadable = withheld(skipped) - if (unreadable) sections.push(unreadable) + if (unreadable) sections.push({ text: unreadable }) const upstreamText = sources .map((id) => { const source = nodes.get(id) @@ -48,14 +107,18 @@ export function buildPrompt( return `## ${source.role} (${source.id})\n\n${output}` }) .filter(Boolean) - if (upstreamText.length) sections.push(`# Upstream output\n\n${upstreamText.join("\n\n")}`) - return sections.join("\n\n") + if (upstreamText.length) sections.push({ text: `# Upstream output\n\n${upstreamText.join("\n\n")}` }) + return sections +} + +export function buildPrompt(...args: Parameters) { + return plainPrompt(buildSections(...args)) } /** * What the card is, where it sits, and who reads it next. * - * A card is a plain `opencode` session with no idea it is part of anything, so + * A card is a plain Omp session with no idea it is part of anything, so * without this a planner asked to "hand this to the architect" does not know an * architect exists — it has never seen the graph. Every card gets the same map, * named by the same `role (id)` label the `# Upstream output` headers use, so @@ -89,9 +152,10 @@ export function pipelineBriefing(pipeline: Pipeline, node: FlowNode) { "", "You are one card in an OpenFlow pipeline: a graph of separate agent sessions run in", "dependency order. Each card is its own session with its own role, model and tools.", - "Cards share no memory and cannot talk to each other. Every card receives the same run", - "task; the only thing that crosses a card boundary is a card's final message, which is", - "pasted verbatim into the prompt of every card wired downstream of it.", + "Cards cannot talk to each other. Every card receives the same run task; project memory", + "may be appended below as untrusted evidence, and otherwise the only thing that crosses a", + "card boundary is a card's final message, which is pasted verbatim into the prompt of", + "every card wired downstream of it.", "", `## Pipeline "${pipeline.name}" — ${pipeline.nodes.length} card(s)${validation.ok ? `, ${validation.layers.length} layer(s)` : ""}`, "", @@ -118,6 +182,16 @@ export function pipelineBriefing(pipeline: Pipeline, node: FlowNode) { ].join("\n") } +/** Round 1 of a swarm: the briefing, the agent's role, the task. */ +export function swarmSections(pipeline: Pipeline, node: FlowNode, input: string, skipped: Attachment[] = []) { + const sections: PromptSection[] = [{ text: swarmBriefing(pipeline, node) }] + if (node.agent.prompt.trim()) sections.push({ text: node.agent.prompt.trim(), own: true }) + if (input.trim()) sections.push({ text: `# Task\n\n${input.trim()}`, own: true }) + const unreadable = withheld(skipped) + if (unreadable) sections.push({ text: unreadable }) + return sections +} + /** * What one agent sends in round `round`. * @@ -136,14 +210,7 @@ export function swarmPrompt( skipped: Attachment[] = [], ) { const rounds = roundsOf(pipeline) - if (round <= 1) { - const sections = [swarmBriefing(pipeline, node)] - if (node.agent.prompt.trim()) sections.push(node.agent.prompt.trim()) - if (input.trim()) sections.push(`# Task\n\n${input.trim()}`) - const unreadable = withheld(skipped) - if (unreadable) sections.push(unreadable) - return sections.join("\n\n") - } + if (round <= 1) return plainPrompt(swarmSections(pipeline, node, input, skipped)) const said = [...peers].filter(([id]) => id !== node.id).map(([id, text]) => `## ${label(pipeline, id)}\n\n${text}`) return [ @@ -166,7 +233,7 @@ export function swarmPrompt( * Who else is in the swarm, how the rounds work, and what the disagreement is * for. * - * An agent is a plain `opencode` session that has never heard of the others, so + * An agent is a plain Omp session that has never heard of the others, so * without this it reads a wall of quoted answers with no idea why. The * anti-convergence lines carry the weight: the failure mode of round 2 is every * agent restating whichever peer sounded most certain, which costs a full round @@ -184,8 +251,9 @@ export function swarmBriefing(pipeline: Pipeline, node: FlowNode) { "", "You are one agent in an OpenFlow swarm: several separate sessions, each with its own", "role, model and tools, all answering the same task at once. You are peers — nobody is in", - "charge, and no agent's answer is the default. You share no memory; the only thing that", - "crosses between you is a final message, quoted into the others' prompts between rounds.", + "charge, and no agent's answer is the default. Project memory may be appended below as", + "untrusted evidence; otherwise the only thing that crosses between you is a final message,", + "quoted into the others' prompts between rounds.", "", `## Swarm "${pipeline.name}" — ${shape.agents.length} agent(s), ${rounds} round(s)`, "", @@ -235,7 +303,7 @@ export function swarmBriefing(pipeline: Pipeline, node: FlowNode) { * seeing them arrive one at a time is exactly the anchoring on the first answer * this card exists to avoid. */ -export function synthesisPrompt( +export function synthesisSections( pipeline: Pipeline, node: FlowNode, finals: Map, @@ -252,42 +320,48 @@ export function synthesisPrompt( .filter(Boolean) const silent = shape.agents.filter((agent) => !finals.get(agent.id)) - const sections = [ - [ - "# OpenFlow", - "", - `You are the synthesizer of an OpenFlow swarm. ${shape.agents.length} agent(s) answered the same task`, - `independently and then read each other over ${rounds} round(s). You took no part. Their final`, - "positions are quoted below, and what you write is the result of the whole run.", - "", - "## Your part", - "", - "- Weigh the positions; do not average them. Where they conflict, decide on the evidence", - " they gave, and say which way you went and why.", - "- Where they agree, say it once. The reader does not need it once per agent.", - "- Name anything all of them missed, if you can see it.", - "- Write for the person who started the run: this is the run's final answer, not a report", - " on the debate.", - ].join("\n"), + const sections: PromptSection[] = [ + { + text: [ + "# OpenFlow", + "", + `You are the synthesizer of an OpenFlow swarm. ${shape.agents.length} agent(s) answered the same task`, + `independently and then read each other over ${rounds} round(s). You took no part. Their final`, + "positions are quoted below, and what you write is the result of the whole run.", + "", + "## Your part", + "", + "- Weigh the positions; do not average them. Where they conflict, decide on the evidence", + " they gave, and say which way you went and why.", + "- Where they agree, say it once. The reader does not need it once per agent.", + "- Name anything all of them missed, if you can see it.", + "- Write for the person who started the run: this is the run's final answer, not a report", + " on the debate.", + ].join("\n"), + }, ] - if (node.agent.prompt.trim()) sections.push(node.agent.prompt.trim()) - if (input.trim()) sections.push(`# Task\n\n${input.trim()}`) + if (node.agent.prompt.trim()) sections.push({ text: node.agent.prompt.trim(), own: true }) + if (input.trim()) sections.push({ text: `# Task\n\n${input.trim()}`, own: true }) const unreadable = withheld(skipped) - if (unreadable) sections.push(unreadable) + if (unreadable) sections.push({ text: unreadable }) if (silent.length) // A dropped agent is a hole in the evidence, and a synthesizer that cannot // see the hole writes a confident verdict over it. - sections.push( - `# Agents that produced nothing\n\n${silent + sections.push({ + text: `# Agents that produced nothing\n\n${silent .map((agent) => `- ${label(pipeline, agent.id)}`) .join("\n")}\n\nThey failed or were stopped. Decide on what you have, and say the swarm was short of them.`, - ) - sections.push( - positions.length + }) + sections.push({ + text: positions.length ? `# Final positions\n\n${positions.join("\n\n")}` : "# Final positions\n\nNone. Every agent in the swarm failed, so there is nothing to weigh; say so rather than answering the task yourself.", - ) - return sections.join("\n\n") + }) + return sections +} + +export function synthesisPrompt(...args: Parameters) { + return plainPrompt(synthesisSections(...args)) } /** @@ -297,13 +371,17 @@ export function synthesisPrompt( * an orchestrator that cannot see what a card is for assigns by guessing at the * label, which is how the reviewer gets asked to write code. */ -export function orchestratorPrompt(pipeline: Pipeline, node: FlowNode, input: string, skipped: Attachment[] = []) { - const sections = [orchestratorBriefing(pipeline, node)] - if (node.agent.prompt.trim()) sections.push(node.agent.prompt.trim()) - if (input.trim()) sections.push(`# Task\n\n${input.trim()}`) +export function orchestratorSections(pipeline: Pipeline, node: FlowNode, input: string, skipped: Attachment[] = []) { + const sections: PromptSection[] = [{ text: orchestratorBriefing(pipeline, node) }] + if (node.agent.prompt.trim()) sections.push({ text: node.agent.prompt.trim(), own: true }) + if (input.trim()) sections.push({ text: `# Task\n\n${input.trim()}`, own: true }) const unreadable = withheld(skipped) - if (unreadable) sections.push(unreadable) - return sections.join("\n\n") + if (unreadable) sections.push({ text: unreadable }) + return sections +} + +export function orchestratorPrompt(...args: Parameters) { + return plainPrompt(orchestratorSections(...args)) } export function orchestratorBriefing(pipeline: Pipeline, node: FlowNode) { @@ -332,8 +410,9 @@ export function orchestratorBriefing(pipeline: Pipeline, node: FlowNode) { ? "You are the orchestrator of an OpenFlow run. You do not do the work: you decide what has to" : "You are a subagent of an OpenFlow run, and you have cards of your own. You do not do the work: you decide what has to", "happen, hand it to the cards below you, read what comes back, and answer. Each card is its own", - "session with its own role, model and tools; they share no memory, cannot talk to each other,", - "and know only the task you write for them.", + "session with its own role, model and tools; they cannot talk to each other, project memory", + "may be appended to their prompts as untrusted evidence, and otherwise they know only the", + "task you write for them.", "", `## Cards you can dispatch to — ${children.length}`, "", @@ -511,7 +590,7 @@ export function verifyPrompt(pipeline: Pipeline, node: FlowNode, result: string, [ "# OpenFlow", "", - "You are the verifier of an OpenFlow run: a separate `opencode` session with your own model", + "You are the verifier of an OpenFlow run: a separate Omp session with your own model", "and tools. The cards have finished. Nothing else will run after you, and no model reads your", "answer — the run is reported as passing or failing on the line you end with.", "", @@ -581,7 +660,7 @@ export function criticPrompt( [ "# OpenFlow", "", - "You are the critic of an OpenFlow gauntlet: a separate `opencode` session with your own", + "You are the critic of an OpenFlow gauntlet: a separate Omp session with your own", `model and tools. ${label(pipeline, parent.id)} dispatched you and is the only thing that`, "reads your answer. You did not build any of this and you have no stake in it.", "", @@ -625,7 +704,7 @@ export function criticPrompt( } /** One subagent's assignment: the run's context, then the job it was given. */ -export function subagentPrompt( +export function subagentSections( pipeline: Pipeline, node: FlowNode, parent: FlowNode, @@ -633,37 +712,44 @@ export function subagentPrompt( input: string, skipped: Attachment[] = [], ) { - const sections = [ - [ - "# OpenFlow", - "", - "You are one card in an OpenFlow run: a separate `opencode` session with your own role, model", - `and tools. ${label(pipeline, parent.id)} dispatched you and is the only thing that reads your`, - "answer. You share no memory with any other card, none of them can be asked anything, and", - "nothing you write goes anywhere else.", - "", - "## Your part", - "", - "- Do the assignment below and stop. If it is impossible or underspecified, say exactly what", - " is missing rather than guessing broadly — the card that dispatched you can fix it and", - " dispatch you again.", - // Measured: one round left seven helper scripts in the deliverable — - // check.js, verify.js, verify_final2.js, fix_sync.js, apply_fix.js and a - // file called `[object Object]`. Cards write scratch files whatever a bar - // says about tidiness, so they are given somewhere to put them. - "- Scripts you write to check or patch your own work are scratch, not deliverables. Put them", - " in a `.scratch/` folder and leave the deliverable holding only what ships.", - "- State any assumption you had to take.", - "- Write for the card that reads you: what it needs to act, in the shortest form that", - " carries it. No preamble.", - ].join("\n"), + const sections: PromptSection[] = [ + { + text: [ + "# OpenFlow", + "", + "You are one card in an OpenFlow run: a separate Omp session with your own role, model", + `and tools. ${label(pipeline, parent.id)} dispatched you and is the only thing that reads your`, + "answer. No other card's session is open to you and none of them can be asked anything —", + "project memory may be appended below as untrusted evidence, and nothing you write goes", + "anywhere else.", + "", + "## Your part", + "", + "- Do the assignment below and stop. If it is impossible or underspecified, say exactly what", + " is missing rather than guessing broadly — the card that dispatched you can fix it and", + " dispatch you again.", + // Measured: one round left seven helper scripts in the deliverable — + // check.js, verify.js, verify_final2.js, fix_sync.js, apply_fix.js and a + // file called `[object Object]`. Cards write scratch files whatever a bar + // says about tidiness, so they are given somewhere to put them. + "- Scripts you write to check or patch your own work are scratch, not deliverables. Put them", + " in a `.scratch/` folder and leave the deliverable holding only what ships.", + "- State any assumption you had to take.", + "- Write for the card that reads you: what it needs to act, in the shortest form that", + " carries it. No preamble.", + ].join("\n"), + }, ] - if (node.agent.prompt.trim()) sections.push(node.agent.prompt.trim()) - if (input.trim()) sections.push(`# What the run is for\n\n${input.trim()}`) + if (node.agent.prompt.trim()) sections.push({ text: node.agent.prompt.trim(), own: true }) + if (input.trim()) sections.push({ text: `# What the run is for\n\n${input.trim()}`, own: true }) const unreadable = withheld(skipped) - if (unreadable) sections.push(unreadable) - sections.push(`# Your assignment\n\n${task.trim()}`) - return sections.join("\n\n") + if (unreadable) sections.push({ text: unreadable }) + sections.push({ text: `# Your assignment\n\n${task.trim()}`, own: true }) + return sections +} + +export function subagentPrompt(...args: Parameters) { + return plainPrompt(subagentSections(...args)) } /** @@ -1072,7 +1158,7 @@ export function reassignPrompt(task: string) { * rather than the run task. Without this it would be briefed as a leaf and then * have its plain answer parsed for a control block it was never told about. */ -export function subOrchestratorPrompt( +export function subOrchestratorSections( pipeline: Pipeline, node: FlowNode, parent: FlowNode, @@ -1080,11 +1166,18 @@ export function subOrchestratorPrompt( input: string, skipped: Attachment[] = [], ) { - const sections = [orchestratorBriefing(pipeline, node)] - if (node.agent.prompt.trim()) sections.push(node.agent.prompt.trim()) - if (input.trim()) sections.push(`# What the run is for\n\n${input.trim()}`) + const sections: PromptSection[] = [{ text: orchestratorBriefing(pipeline, node) }] + if (node.agent.prompt.trim()) sections.push({ text: node.agent.prompt.trim(), own: true }) + if (input.trim()) sections.push({ text: `# What the run is for\n\n${input.trim()}`, own: true }) const unreadable = withheld(skipped) - if (unreadable) sections.push(unreadable) - sections.push(`# Your assignment\n\n${label(pipeline, parent.id)} dispatched you with this:\n\n${task.trim()}`) - return sections.join("\n\n") + if (unreadable) sections.push({ text: unreadable }) + sections.push({ + text: `# Your assignment\n\n${label(pipeline, parent.id)} dispatched you with this:\n\n${task.trim()}`, + own: true, + }) + return sections +} + +export function subOrchestratorPrompt(...args: Parameters) { + return plainPrompt(subOrchestratorSections(...args)) } diff --git a/packages/flow/src/graph/roles.ts b/packages/flow/src/graph/roles.ts index edda76f788..37d997d83a 100644 --- a/packages/flow/src/graph/roles.ts +++ b/packages/flow/src/graph/roles.ts @@ -9,10 +9,10 @@ export type Role = { agent: FlowAgent } -// planner, architect, coder, reviewer and custom carry opencode's own agent -// colours (plan, writer, build, review, explore) at their dark values, so a role -// reads the same here as it does in the app — do not "fix" them back to a -// generic palette. `synthesizer` is OpenFlow's own and says so where it sits. +// planner, architect, coder, reviewer and custom carry the agent colours the +// rest of the app uses (plan, writer, build, review, explore) at their dark +// values, so a role reads the same here as it does there — do not "fix" them +// back to a generic palette. `synthesizer` is OpenFlow's own and says so where it sits. export const ROLES: Role[] = [ { id: "planner", @@ -57,7 +57,7 @@ export const ROLES: Role[] = [ tools: { read: true, grep: true, glob: true, edit: false, bash: false }, }, }, - // The two below are OpenFlow's own, because opencode has no agent that does + // The two below are OpenFlow's own, because none of the five above does // either job: reading a whole swarm and deciding, and handing work out to // cards it can see. Cyan and amber are the hues the five above leave free. { diff --git a/packages/flow/src/graph/types.test.ts b/packages/flow/src/graph/types.test.ts index a5e0e864fa..1cc35d14e8 100644 --- a/packages/flow/src/graph/types.test.ts +++ b/packages/flow/src/graph/types.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { isPipeline } from "./pipeline-io" import { pipeline } from "./test-support" import { DEFAULT_DEPTH, @@ -22,6 +23,8 @@ import { MODES, roundsOf, type Pipeline, + type RunLog, + type RunNodeLog, } from "./types" describe("modeOf", () => { @@ -140,3 +143,67 @@ describe("gauntlet settings", () => { expect(gauntletOf(gauntlet(orchestration(pipeline("a")), { maxSpend: Number.NaN }))!.maxSpend).toBe(DEFAULT_MAX_SPEND) }) }) + +describe("what a card run records", () => { + const node: RunNodeLog = { + id: "a", + role: "coder", + status: "done", + prompt: "spec", + uplift: { status: "ok", original: "do it", uplifted: "spec", elapsedMs: 12 }, + memory: { + read: { status: "ok", items: [{ scope: "repo:o/n", subject: "openflow/x/r/b", preview: "p", origin: "brief" }] }, + write: { status: "stored", subject: "openflow/x/r/a" }, + }, + swarm: { + mode: "auto", + active: true, + counts: { queued: 0, running: 1, completed: 0, failed: 0, cancelled: 0 }, + agents: [{ agent: "a05-backend", state: "running", at: 1 }], + updatedAt: 1, + }, + } + const log = (nodes: RunNodeLog[]): RunLog => ({ + id: "run-1", + pipeline: "p", + pipelineID: "p1", + input: "x", + status: "done", + started: 1, + nodes, + }) + + test("the original and uplifted prompts, memory and swarm state survive a JSON round trip", () => { + const back = JSON.parse(JSON.stringify(log([node]))) as RunLog + expect(back.nodes[0]).toEqual(node) + expect(back.nodes[0].uplift?.original).toBe("do it") + expect(back.nodes[0].prompt).toBe(back.nodes[0].uplift?.uplifted) + }) + + test("a log written before these fields existed still reads, with them absent", () => { + const old = JSON.parse('{"id":"run-0","pipeline":"p","pipelineID":"p1","input":"x","status":"done","started":1,"nodes":[{"id":"a","role":"coder","status":"done","agent":"flow-a","prompt":"sent"}]}') as RunLog + expect(old.nodes[0].uplift).toBeUndefined() + expect(old.nodes[0].memory).toBeUndefined() + expect(old.nodes[0].swarm).toBeUndefined() + expect(old.nodes[0].prompt).toBe("sent") + }) +}) + +describe("a card's engine fields in a saved canvas", () => { + test("swarm and uplift persist through JSON and a file with them still imports", () => { + const graph: Pipeline = { ...pipeline("a"), defaultSwarm: "auto" } + graph.nodes[0].agent.swarm = "always" + graph.nodes[0].agent.uplift = false + const back = JSON.parse(JSON.stringify(graph)) as Pipeline + expect(back.defaultSwarm).toBe("auto") + expect(back.nodes[0].agent.swarm).toBe("always") + expect(back.nodes[0].agent.uplift).toBe(false) + expect(isPipeline(back)).toBe(true) + }) + + test("a file from the opencode era, naming an agent, still imports", () => { + const graph = pipeline("a") + graph.nodes[0].agent.name = "flow-a" + expect(isPipeline(JSON.parse(JSON.stringify(graph)))).toBe(true) + }) +}) diff --git a/packages/flow/src/graph/types.ts b/packages/flow/src/graph/types.ts index 69c2830eef..81ff2948eb 100644 --- a/packages/flow/src/graph/types.ts +++ b/packages/flow/src/graph/types.ts @@ -1,3 +1,6 @@ +import type { MemoryInfo } from "../../shared/memory" +import type { SwarmMode, SwarmStatus } from "../../shared/swarm" +import type { UpliftInfo } from "../../shared/uplift" import type { LedgerRound } from "./ledger" export type NodeStatus = "idle" | "queued" | "running" | "done" | "error" | "skipped" | "stopped" @@ -37,13 +40,16 @@ export type Attachment = { } export type FlowAgent = { - /** Existing opencode agent to run this node as. Empty = server default. */ + /** + * An opencode agent name, from before cards ran on Omp. Tolerated on parse so + * an old file still loads; nothing reads it. + */ name?: string - /** "providerID/modelID". Empty = agent/server default. */ + /** "providerID/modelID". Empty = Omp's configured default model. */ model?: string /** Role instructions, prepended to every prompt this node sends. */ prompt: string - /** Tool allowlist written into the generated agent config. */ + /** Tool toggles in OpenFlow's vocabulary, folded by `toolMap` into the card's launch spec. */ tools?: Record /** * MCP servers this node may use, by config name. `undefined` means the node @@ -53,6 +59,16 @@ export type FlowAgent = { mcp?: string[] /** Files pinned to this node, sent with every run. */ attachments?: Attachment[] + /** + * Agent Swarm under this card's Omp session. Absent inherits the canvas's + * `defaultSwarm`, and then the host's settings. + */ + swarm?: SwarmMode + /** + * `false` sends this card's task as written, skipping Prompt Uplift. Absent + * means uplifted — a per-card opt-out, never an opt-in. + */ + uplift?: boolean } export type FlowNode = { @@ -130,6 +146,13 @@ export type Pipeline = { * an answer that has been judged. */ verify?: Verify + /** + * The Agent Swarm mode every card launches with unless its own + * `agent.swarm` says otherwise. Absent defers to the host's settings. + * Named apart from `mode: "swarm"`, which is how the canvas schedules cards; + * this is whether a card's own Omp session loads the swarm extension. + */ + defaultSwarm?: SwarmMode } /** @@ -314,8 +337,16 @@ export type RunNodeLog = { /** Attachment names actually sent, and those the model could not read. */ attachments?: { sent: string[]; skipped: string[] } model?: string + /** An opencode agent name, from a run logged before cards ran on Omp. Never written now. */ agent?: string + /** The text actually sent to the session: uplifted, with context and memory, when those ran. */ prompt?: string + /** Prompt Uplift: the original task and what it became, so both survive in the log. */ + uplift?: UpliftInfo + /** Shared memory: what this card read before it ran and what it wrote after. */ + memory?: MemoryInfo + /** Agent Swarm activity under this card's session. */ + swarm?: SwarmStatus output?: string error?: string started?: number diff --git a/packages/flow/src/graph/validate.test.ts b/packages/flow/src/graph/validate.test.ts index 621252b21f..21262cfc32 100644 --- a/packages/flow/src/graph/validate.test.ts +++ b/packages/flow/src/graph/validate.test.ts @@ -382,10 +382,10 @@ describe("preflight", () => { expect(result.blocking[0].message).toContain("'a'") }) - test("a node with no model but a named agent is fine — it runs on the agent's default", () => { + test("a legacy opencode agent name on a node is tolerated and changes nothing", () => { const graph = pipeline("a") graph.nodes[0].agent.name = "reviewer" - expect(preflight(graph, unlocked()).blocking).toEqual([]) + expect(preflight(graph, unlocked()).blocking.map((problem) => problem.kind)).toEqual(["no-model"]) }) test("a model that no connected provider can run blocks and names the node", () => { @@ -401,9 +401,8 @@ describe("preflight", () => { }) test("a node that sets no tool permissions warns but does not block", () => { - // The generated agent gets a `permission` block only from `agent.tools`, so - // a node that declares none inherits the default agent's — edit, write and - // bash included. + // The launch spec carries a tool list only from `agent.tools`, so a node + // that declares none runs with every tool — edit and bash included. const graph = withModel(pipeline("a"), "opencode/x") graph.nodes[0].agent.tools = undefined const result = preflight(graph, unlocked("opencode/x")) @@ -412,31 +411,21 @@ describe("preflight", () => { }) test("declaring tools is what restricts a node, so it silences the warning", () => { - // This warned the other way round once: it fired for six cards that all ran - // under restricted generated agents, because `agent.name` is filled when the - // run starts and preflight reads it before that. const graph = withModel(pipeline("a"), "opencode/x") graph.nodes[0].agent.tools = { bash: true } expect(preflight(graph, unlocked("opencode/x")).warnings).toEqual([]) }) - test("a named agent silences it too — that agent's own permissions apply", () => { - const graph = withModel(pipeline("a"), "opencode/x") - graph.nodes[0].agent.tools = undefined - graph.nodes[0].agent.name = "coder" - expect(preflight(graph, unlocked("opencode/x")).warnings).toEqual([]) - }) - test("an isolated node in a multi-node graph warns", () => { const graph = withModel(pipeline("a->b", "lonely"), "opencode/x") const result = preflight(graph, unlocked("opencode/x")) expect(result.warnings.map((problem) => [problem.kind, problem.nodeId])).toEqual([["isolated", "lonely"]]) }) - test("two nodes generating the same agent id block", () => { + test("two nodes generating the same card key block", () => { // The key carries the node id, so this only happens if two nodes are given - // the same id — but a collision silently merges their permission blocks, - // which is worth refusing rather than running. + // the same id — but a collision would share one state directory between + // them, which is worth refusing rather than running. const graph = withModel(pipeline("a", "b"), "opencode/x") graph.nodes[1].id = graph.nodes[0].id graph.nodes[1].role = graph.nodes[0].role @@ -458,28 +447,9 @@ describe("preflight", () => { expect(preflight(graph, unlocked("opencode/x")).warnings).toEqual([]) }) - test("an unreachable engine blames the engine once and suppresses every locked model", () => { - // The exact false alarm: with the engine down nothing can read the catalog, - // so all three nodes look like they picked a model nobody can run. - const graph = withModel(pipeline("a->b", "b->c"), "anthropic/claude-sonnet-4") - const result = preflight(graph, { unlockedModels: new Set(), engineReachable: false }) - - expect(result.blocking.map((problem) => problem.kind)).toEqual(["engine-unreachable"]) - expect(result.blocking[0].message).toContain("opencode serve") - expect(result.blocking[0].nodeId).toBeUndefined() - }) - - test("an unreachable engine still reports problems that are not about models", () => { - const graph = withModel(pipeline("a->b", "b->a"), "opencode/x") - const kinds = preflight(graph, { unlockedModels: new Set(), engineReachable: false }).blocking.map( - (problem) => problem.kind, - ) - expect(kinds).toEqual(["engine-unreachable", "structure"]) - }) - test("a reachable engine still blocks a model no connected provider can run", () => { const graph = withModel(pipeline("a"), "groq/llama") - const result = preflight(graph, { unlockedModels: new Set(["opencode/x"]), engineReachable: true }) + const result = preflight(graph, unlocked("opencode/x")) expect(result.blocking.map((problem) => problem.kind)).toEqual(["locked-model"]) }) }) diff --git a/packages/flow/src/graph/validate.ts b/packages/flow/src/graph/validate.ts index c651afa795..05723fd222 100644 --- a/packages/flow/src/graph/validate.ts +++ b/packages/flow/src/graph/validate.ts @@ -1,4 +1,4 @@ -import { agentKey, toolMap } from "../server/store" +import { agentKey, toolMap } from "../server/card-spec" import { isCritic, orchestrationShape } from "./orchestration" import { swarmShape } from "./swarm" import { depthOf, dispatchesOf, gauntletOf, modeOf, verifyOf, type Pipeline } from "./types" @@ -71,26 +71,10 @@ export type Preflight = { blocking: Problem[]; warnings: Problem[] } * right now (a keyed, runnable model). A model set but absent from it is the * "keyed provider that still 401s" case, so it blocks rather than warns. * - * `engineReachable: false` means the model catalog could not be read at all, so - * `unlockedModels` is empty for a reason that has nothing to do with any node. - * Only an explicit `false` counts — an unstated engine is assumed up. */ -export function preflight( - pipeline: Pipeline, - ctx: { unlockedModels: Set; engineReachable?: boolean }, -): Preflight { +export function preflight(pipeline: Pipeline, ctx: { unlockedModels: Set }): Preflight { const blocking: Problem[] = [] const warnings: Problem[] = [] - const offline = ctx.engineReachable === false - - // With the engine down every model looks locked, so one true problem replaces - // a wall of confident, specific, wrong per-node diagnoses. - if (offline) - blocking.push({ - kind: "engine-unreachable", - message: - "OpenFlow cannot reach `opencode serve`, so no model can run and no provider can be read — start the engine with `bun openflow.ts`, then try again", - }) const shape = shapeProblems(pipeline) blocking.push(...shape.blocking) @@ -99,14 +83,12 @@ export function preflight( for (const node of pipeline.nodes) { const model = node.agent.model if (!model) { - // No model and no agent to fall back on means nothing to run it on. - if (!node.agent.name) - blocking.push({ - nodeId: node.id, - kind: "no-model", - message: `Node '${node.role}' has no model — pick one or set a default`, - }) - } else if (!offline && !ctx.unlockedModels.has(model)) { + blocking.push({ + nodeId: node.id, + kind: "no-model", + message: `Node '${node.role}' has no model — pick one or set a default`, + }) + } else if (!ctx.unlockedModels.has(model)) { blocking.push({ nodeId: node.id, kind: "locked-model", @@ -114,31 +96,22 @@ export function preflight( }) } - // A node that says nothing about tools runs unrestricted. - // - // This used to warn the other way round — "can edit files but has no agent" - // — and it was backwards twice over. Every node becomes a generated agent - // the moment a run starts (`syncAgents` fills `agent.name` from - // `agentKey`), so an empty name at preflight means nothing; it is filled a - // second later, which is why the warning fired for six cards that all ran - // under restricted agents. And declaring tools is what *creates* the - // restriction: `agentBlock` writes a `permission` block only from - // `agent.tools`, so a node that declares none inherits the default agent's - // permissions — edit, write and bash included. The hazard is silence, not - // toggles. + // A node that says nothing about tools runs unrestricted: the launch spec + // (`cardSpec`) carries a tool list only when the card declares one, and a + // card without it gets every tool Omp has — edit and bash included. The + // hazard is silence, not toggles. const tools = node.agent.tools - if (!node.agent.name && !(tools && Object.keys(tools).length)) + if (!(tools && Object.keys(tools).length)) warnings.push({ nodeId: node.id, kind: "unrestricted-write", - message: `Node '${node.role}' sets no tool permissions, so it runs with the default agent's — it can edit files and run commands`, + message: `Node '${node.role}' sets no tool permissions, so it runs with every tool — it can edit files and run commands`, }) } - // Two nodes generating the same agent id collapse into one merged agent, and - // the last one written decides both nodes' tool permissions — a node the user - // restricted would run with the other's edit/bash access. The key is unique - // per node today; this refuses the run rather than trusting that to hold. + // Two nodes with the same card key would share whatever is keyed on it — a + // session's state directory, a swarm task store. The key is unique per node + // today; this refuses the run rather than trusting that to hold. const owners = new Map() for (const node of pipeline.nodes) { const key = agentKey(pipeline, node) @@ -147,7 +120,7 @@ export function preflight( blocking.push({ nodeId: node.id, kind: "duplicate-agent", - message: `Nodes '${owner}' and '${node.role}' both generate the agent id '${key}' — one would overwrite the other's tool permissions`, + message: `Nodes '${owner}' and '${node.role}' both generate the card key '${key}' — they would share one session's state`, }) owners.set(key, node.role) } @@ -293,9 +266,8 @@ function swarmProblems(pipeline: Pipeline): Preflight { // that does not exist. A swarm is a debate; its output is the synthesizer's // message, and nothing a peer writes to disk is part of it. // - // Only tools switched off explicitly are denied — an unlisted tool inherits - // the default agent's permission, which is allow — so `{ read: true }` can - // write. The synthesizer is not checked: it runs alone, after the rounds. + // Only tools switched off explicitly are denied — an unlisted tool is not + // restricted, which means allowed — so `{ read: true }` can write. The synthesizer is not checked: it runs alone, after the rounds. const writers = shape.agents.filter((agent) => { const tools = toolMap(agent.agent.tools) return tools.edit !== false || tools.bash !== false diff --git a/packages/flow/src/graph/worktree.ts b/packages/flow/src/graph/worktree.ts index 1039d1f245..859eb95cd4 100644 --- a/packages/flow/src/graph/worktree.ts +++ b/packages/flow/src/graph/worktree.ts @@ -1,4 +1,4 @@ -import { toolMap } from "../server/store" +import { toolMap } from "../server/card-spec" import type { FlowNode } from "./types" /** diff --git a/packages/flow/src/server/activity.test.ts b/packages/flow/src/server/activity.test.ts index d1fca19340..d2757d477a 100644 --- a/packages/flow/src/server/activity.test.ts +++ b/packages/flow/src/server/activity.test.ts @@ -118,10 +118,29 @@ group("createActivity", () => { const { activity, last } = collector({ s1: "n1" }) activity.consume({ type: "session.next.step.started", - data: { sessionID: "s1", assistantMessageID: "m1", agent: "build", model: { providerID: "opencode", id: "zen" } }, + data: { sessionID: "s1", assistantMessageID: "m1", agent: "build", model: { providerID: "anthropic", id: "claude-opus-5" } }, }) expect(last().event.kind).toBe("step") - expect(last().event.title).toBe("build · opencode/zen") + expect(last().event.title).toBe("build · anthropic/claude-opus-5") + }) + + test("an omp notice becomes a note row on the card that owns the session, errors flagged", () => { + const { activity, seen, last } = collector({ s1: "n1" }) + activity.consume({ type: "flow.notice", data: { sessionID: "s1", level: "info", message: "extension loaded" } }) + expect(last().node).toBe("n1") + expect(last().event.kind).toBe("note") + expect(last().event.title).toBe("extension loaded") + expect(last().event.status).toBeUndefined() + activity.consume({ type: "flow.notice", data: { sessionID: "s1", level: "error", message: "extension crashed" } }) + expect(last().event.status).toBe("error") + // each notice is its own row, not an upsert of the previous one + expect(new Set(seen.map((entry) => entry.event.id)).size).toBe(2) + }) + + test("a notice with no session belongs to no card and is dropped", () => { + const { activity, seen } = collector({ s1: "n1" }) + activity.consume({ type: "flow.notice", data: { level: "info", message: "omp restarted" } }) + expect(seen).toHaveLength(0) }) test("a body longer than the live limit is clipped, and says so", () => { diff --git a/packages/flow/src/server/activity.ts b/packages/flow/src/server/activity.ts index bba75b680d..e8aed0602f 100644 --- a/packages/flow/src/server/activity.ts +++ b/packages/flow/src/server/activity.ts @@ -1,5 +1,5 @@ import type { NodeEvent } from "../graph/types" -import type { BusEvent } from "./client" +import type { BusEvent } from "../../shared/engine" /** * How much of one body is kept while the app holds the run in memory. Long @@ -14,7 +14,7 @@ export const EVENT_LIMIT = 300 /** Events kept per node in the saved run log. */ export const PERSIST_EVENT_LIMIT = 200 -/** The tool opencode spawns a subagent with. Its child session is nested under it. */ +/** The tool omp spawns a subagent with. Its child session is nested under it. */ const SUBAGENT_TOOL = "task" /** @@ -71,6 +71,9 @@ export function createActivity(options: ActivityOptions) { */ const names = new Map() + /** Notices carry no id of their own; each is its own row. */ + let notices = 0 + function place(sessionID: string) { const own = options.owner(sessionID) if (own) return { node: own, depth: 0, callID: undefined as string | undefined } @@ -224,6 +227,21 @@ export function createActivity(options: ActivityOptions) { }) } + // omp's `notice` and extension errors. A notice with no session + // belongs to no card, and `consume` already returned for it. + case "flow.notice": { + const text = String(data.message ?? "") + if (!text) return + notices += 1 + return emit(sessionID, { + id: `notice:${notices}`, + kind: "note", + title: oneLine(text, 120), + status: data.level === "error" ? "error" : undefined, + body: text, + }) + } + case "session.next.step.failed": { return emit(sessionID, { id: `step:${data.assistantMessageID}:failed`, diff --git a/packages/flow/src/server/card-spec.test.ts b/packages/flow/src/server/card-spec.test.ts new file mode 100644 index 0000000000..d69e0ae12d --- /dev/null +++ b/packages/flow/src/server/card-spec.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test" +import { pipeline } from "../graph/test-support" +import type { Pipeline } from "../graph/types" +import { agentKey, cardSpec, swarmOf, TOOL_ACTIONS, TOOLS, toolMap } from "./card-spec" + +describe("toolMap", () => { + test("folds the edit aliases onto one toggle, and a deny anywhere in the group wins", () => { + expect(toolMap({ write: true, patch: false, read: true })).toEqual({ edit: false, read: true }) + expect(toolMap({ edit: true, "apply-patch": true })).toEqual({ edit: true }) + }) + + test("drops names outside the vocabulary and values that are not booleans", () => { + expect(toolMap({ ghost: true, read: "yes" as unknown as boolean, bash: false })).toEqual({ bash: false }) + expect(toolMap(undefined)).toEqual({}) + }) + + test("the vocabulary is the names the host translates", () => { + expect(TOOLS).toEqual(Object.keys(TOOL_ACTIONS)) + expect(TOOLS).toEqual([ + "read", + "grep", + "glob", + "edit", + "bash", + "webfetch", + "websearch", + "todowrite", + "skill", + "question", + ]) + }) +}) + +describe("agentKey", () => { + test("is pipeline, role and node id, sanitised and lower-cased", () => { + const graph = { ...pipeline("a"), name: "My Flow!" } + graph.nodes[0].role = "Code Reviewer" + expect(agentKey(graph, graph.nodes[0])).toBe("my-flow--code-reviewer-a") + }) + + test("two cards sharing a role still get different keys", () => { + const graph = pipeline("a", "b") + graph.nodes[1].role = graph.nodes[0].role + expect(agentKey(graph, graph.nodes[0])).not.toBe(agentKey(graph, graph.nodes[1])) + }) +}) + +describe("cardSpec", () => { + test("carries the key, the role and the role instructions", () => { + const graph = pipeline("a") + graph.nodes[0].agent.prompt = "You are the planner." + expect(cardSpec(graph, graph.nodes[0])).toEqual({ + key: "test-a-a", + role: "a", + prompt: "You are the planner.", + }) + }) + + test("a blank role prompt, no tool map and no mcp list say nothing — every tool and server stays available", () => { + const graph = pipeline("a") + graph.nodes[0].agent.prompt = " " + graph.nodes[0].agent.tools = {} + const spec = cardSpec(graph, graph.nodes[0]) + expect(spec).not.toHaveProperty("prompt") + expect(spec).not.toHaveProperty("tools") + expect(spec).not.toHaveProperty("mcp") + expect(spec).not.toHaveProperty("dispatch") + }) + + test("tools go out folded onto the vocabulary", () => { + const graph = pipeline("a") + graph.nodes[0].agent.tools = { write: false, read: true, nonsense: true } + expect(cardSpec(graph, graph.nodes[0]).tools).toEqual({ edit: false, read: true }) + }) + + test("an empty mcp allowlist means none and is not dropped; the list is copied", () => { + const graph = pipeline("a") + graph.nodes[0].agent.mcp = [] + expect(cardSpec(graph, graph.nodes[0]).mcp).toEqual([]) + graph.nodes[0].agent.mcp = ["docs"] + const spec = cardSpec(graph, graph.nodes[0]) + expect(spec.mcp).toEqual(["docs"]) + expect(spec.mcp).not.toBe(graph.nodes[0].agent.mcp) + }) + + test("only a card with cards of its own, in an orchestration, gets the dispatch tools", () => { + const tree: Pipeline = { ...pipeline("boss->mid", "mid->leaf"), mode: "orchestration" } + expect(cardSpec(tree, tree.nodes[0]).dispatch).toBe(true) + expect(cardSpec(tree, tree.nodes[1]).dispatch).toBe(true) + expect(cardSpec(tree, tree.nodes[2]).dispatch).toBeUndefined() + // The same edges in a pipeline are wiring, not dispatch. + expect(cardSpec({ ...tree, mode: "pipeline" }, tree.nodes[0]).dispatch).toBeUndefined() + }) +}) + +describe("swarmOf", () => { + test("the card's own mode beats the canvas default, which beats the host's", () => { + const graph = pipeline("a") + expect(swarmOf(graph, graph.nodes[0])).toBeUndefined() + graph.defaultSwarm = "auto" + expect(swarmOf(graph, graph.nodes[0])).toBe("auto") + graph.nodes[0].agent.swarm = "off" + expect(swarmOf(graph, graph.nodes[0])).toBe("off") + }) + + test("a mode this build does not know is ignored instead of passed to the host", () => { + const graph = pipeline("a") + graph.nodes[0].agent.swarm = "sometimes" as never + graph.defaultSwarm = "always" + expect(swarmOf(graph, graph.nodes[0])).toBe("always") + graph.defaultSwarm = "bogus" as never + expect(swarmOf(graph, graph.nodes[0])).toBeUndefined() + }) +}) diff --git a/packages/flow/src/server/card-spec.ts b/packages/flow/src/server/card-spec.ts new file mode 100644 index 0000000000..e6ad6f22b3 --- /dev/null +++ b/packages/flow/src/server/card-spec.ts @@ -0,0 +1,92 @@ +import type { CardSpec } from "../../shared/engine" +import { SWARM_MODES, type SwarmMode } from "../../shared/swarm" +import { orchestrationShape } from "../graph/orchestration" +import { modeOf, type FlowNode, type Pipeline } from "../graph/types" + +/** + * Tool names an `agent.tools` map can switch off, in OpenFlow's own vocabulary. + * + * The host translates these onto Omp's tools when it launches the card, so this + * list is the only guard against a typo becoming a toggle that does nothing. + * `write`, `edit` and `patch` collapse onto the single `edit` toggle (see + * `TOOL_ALIASES`): offering them separately lets a graph ask for contradictory + * states. `question` is what lets a card stop and ask a person something; a card + * with it off runs headless and guesses instead. + */ +export const TOOL_ACTIONS: Record = { + read: "read", + grep: "grep", + glob: "glob", + edit: "edit", + bash: "bash", + webfetch: "webfetch", + websearch: "websearch", + todowrite: "todowrite", + skill: "skill", + question: "question", +} + +export const TOOLS = Object.keys(TOOL_ACTIONS) + +/** Older graphs (and the role presets) name the edit capability three ways. */ +const TOOL_ALIASES: Record = { + write: "edit", + patch: "edit", + "apply-patch": "edit", +} + +/** + * Folds a node's tool map onto the names the host acts on. Aliases merge, and a + * deny anywhere in a merged group wins — losing a restriction on the way to the + * launch would hand a card more access than the graph asked for. + */ +export function toolMap(tools: Record | undefined) { + const merged: Record = {} + for (const [name, value] of Object.entries(tools ?? {})) { + if (typeof value !== "boolean") continue + const resolved = TOOL_ALIASES[name] ?? name + if (!(resolved in TOOL_ACTIONS)) continue + merged[resolved] = resolved in merged ? merged[resolved] && value : value + } + return merged +} + +/** + * The stable per-card key: pipeline, role and node id, sanitised. + * + * The node id is part of it because a role is not unique — two `coder` cards + * would otherwise share one key, and whatever is keyed on it (a state directory, + * a swarm task store) would be shared between them. + */ +export function agentKey(pipeline: Pipeline, node: FlowNode) { + return `${pipeline.name}-${node.role}-${node.id}`.replace(/[^a-zA-Z0-9-_]/g, "-").toLowerCase() +} + +/** + * The swarm mode a card launches with, or `undefined` for the host's settings + * default. A value this build does not know is ignored rather than passed on: a + * hand-edited file must not be able to name a mode the host would reject. + */ +export function swarmOf(pipeline: Pipeline, node: FlowNode): SwarmMode | undefined { + const own = node.agent.swarm + if (own && SWARM_MODES.includes(own)) return own + const fallback = pipeline.defaultSwarm + return fallback && SWARM_MODES.includes(fallback) ? fallback : undefined +} + +/** Everything the host needs to launch one card, in OpenFlow's own vocabulary. */ +export function cardSpec(pipeline: Pipeline, node: FlowNode): CardSpec { + const tools = toolMap(node.agent.tools) + const orchestrates = modeOf(pipeline) === "orchestration" && orchestrationShape(pipeline).children(node.id).length > 0 + return { + key: agentKey(pipeline, node), + role: node.role, + ...(node.agent.prompt.trim() ? { prompt: node.agent.prompt } : {}), + // Absent means every tool: a card authored before toggles existed, or one + // that names none, was never restricted and must not become so now. + ...(Object.keys(tools).length ? { tools } : {}), + // `undefined` is "every server"; an empty array is "none" — a different thing. + ...(node.agent.mcp ? { mcp: [...node.agent.mcp] } : {}), + ...(orchestrates ? { dispatch: true } : {}), + } +} diff --git a/packages/flow/src/server/client.test.ts b/packages/flow/src/server/client.test.ts index a55064b2b8..839f478018 100644 --- a/packages/flow/src/server/client.test.ts +++ b/packages/flow/src/server/client.test.ts @@ -1,19 +1,140 @@ -import { describe as suite, expect, test } from "bun:test" -import { describe, parseModel, formatModel, turnText } from "./client" +import { afterAll, beforeAll, beforeEach, describe as suite, expect, test } from "bun:test" +import type { BusEvent, EngineApi, WaitResult } from "../../shared/engine" +import { ENGINE_BASE } from "../../shared/engine" +import * as client from "./client" + +const { accepts, describe, formatModel, parseModel } = client + +/** Compile-time proof that the module is a complete `EngineApi`. */ +export const engineApi = client satisfies EngineApi + +type Seen = { method: string; path: string; search: string; body: unknown } + +const seen: Seen[] = [] +/** What `/wait` answers per session id; anything else is "missing". */ +const waits = new Map() +/** Transcript per session id for `/final`. */ +const finals = new Map() +/** What the `events` route writes, chunk by chunk, then closes (or holds open when `hold`). */ +let eventChunks: string[] = [] +let hold = false +let streamCancelled = Promise.withResolvers() +let waitAborted = Promise.withResolvers() +let waitHeld = Promise.withResolvers() + +function reply(status: number, body: unknown) { + return new Response(body === undefined ? null : JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }) +} + +const server = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url) + const text = await request.text() + const body = text ? JSON.parse(text) : undefined + seen.push({ method: request.method, path: url.pathname, search: url.search, body }) + + if (url.pathname === "/flow/api/context") { + return reply(200, { project: "/tmp/project", pipelines: "/tmp/p", runs: "/tmp/r", generated: "/tmp/g" }) + } + if (url.pathname === `${ENGINE_BASE}/health`) { + return reply(200, { ok: true, omp: { available: true, version: "18.12.2" }, sessions: { live: 0 } }) + } + if (url.pathname === `${ENGINE_BASE}/models`) { + return reply(200, [{ providerID: "anthropic", id: "claude-opus-5", capabilities: { input: ["text", "image"] } }]) + } + if (url.pathname === `${ENGINE_BASE}/events`) { + const encoder = new TextEncoder() + return new Response( + new ReadableStream({ + start(controller) { + for (const chunk of eventChunks) controller.enqueue(encoder.encode(chunk)) + if (!hold) controller.close() + }, + cancel() { + streamCancelled.resolve() + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ) + } + if (url.pathname === `${ENGINE_BASE}/runs/release`) return reply(200, { released: 1 }) + if (url.pathname === `${ENGINE_BASE}/sessions` && request.method === "POST") { + if (body.model === "openrouter/rate-limited") { + return reply(429, { error: "Provider request failed with HTTP 429 Too Many Requests: slow down" }) + } + return reply(200, { id: body.model === "openai/gpt-broken" ? "ses_broken" : "ses_1" }) + } + if (url.pathname === `${ENGINE_BASE}/sessions`) { + return reply(200, [{ id: "ses_1", title: "coder", created: 1, updated: 2 }]) + } + + const match = /^\/flow\/api\/engine\/sessions\/([^/]+)(\/.*)?$/.exec(url.pathname) + if (!match) return reply(404, { error: "no such route" }) + const id = decodeURIComponent(match[1]) + const tail = match[2] ?? "" + + if (tail === "" && request.method === "DELETE") return reply(200, { released: id }) + if (tail === "/prompt") return reply(200, { accepted: true }) + if (tail === "/wait") { + if (id === "hang") { + waitHeld.resolve() + const held = Promise.withResolvers() + request.signal.addEventListener( + "abort", + () => { + waitAborted.resolve() + held.resolve(reply(499, { error: "client closed request" })) + }, + { once: true }, + ) + return held.promise + } + return reply(200, waits.get(id) ?? { status: "missing" }) + } + if (tail === "/interrupt") return id === "broken" ? reply(500, { error: "boom" }) : reply(200, {}) + if (tail === "/final" && id === "gone") return reply(404, { error: "session not found: gone" }) + if (tail === "/final") return reply(200, finals.get(id) ?? { text: "" }) + if (tail === "/transcript") return reply(200, [{ role: "user", text: "hello" }]) + if (tail === "/steps") { + return reply(200, [{ messageID: "m1", model: "anthropic/claude-opus-5", tokens: { input: 1, output: 2, reasoning: 0, cacheRead: 0, cacheWrite: 0 } }]) + } + if (tail === "/calls") return reply(200, [{ id: "c1", name: "bash", input: { command: "ls" } }]) + if (tail.startsWith("/permission/") || tail.startsWith("/question/")) return reply(200, { ok: true }) + return reply(404, { error: `session not found: ${id}` }) + }, +}) + +beforeAll(() => client.setOrigin(server.url.origin)) +afterAll(async () => { + client.setOrigin("") + await server.stop(true) +}) +beforeEach(() => { + seen.length = 0 + waits.clear() + finals.clear() + eventChunks = [] + hold = false + streamCancelled = Promise.withResolvers() + waitAborted = Promise.withResolvers() + waitHeld = Promise.withResolvers() +}) suite("describe", () => { test("prefers a message field over the raw body", () => { - expect(describe({ message: "Model glm-4.7-free is not supported" })).toBe( - "Model glm-4.7-free is not supported", - ) + expect(describe({ message: "Model claude-opus-5 is not supported" })).toBe("Model claude-opus-5 is not supported") }) - test("reads the nested message the opencode server nests under data", () => { + test("reads a message nested under data", () => { expect(describe({ data: { message: "no such agent" } })).toBe("no such agent") }) - test("reads an error field, which is the key the flow store answers with", () => { - expect(describe({ error: "pipeline not found" })).toBe("pipeline not found") + test("reads the error field of an engine wire body", () => { + expect(describe({ error: "session not found: ses_9" })).toBe("session not found: ses_9") }) test("prefers message over error when a body carries both", () => { @@ -24,123 +145,274 @@ suite("describe", () => { expect(describe({ error: { code: 500 } })).toBe('{"error":{"code":500}}') }) - // Both hosts proxy `/api` to `opencode serve`, and both must render an - // unreachable engine as a sentence. These are the literal bodies they emit; - // a brace in the output means describe() fell through to JSON.stringify. - test("renders both hosts' proxy failures as prose, never as JSON", () => { - const bodies = [ - // server.ts, 502 - { message: "cannot reach opencode serve at http://127.0.0.1:4096: fetch failed" }, - // server.ts, 504 - { - message: - "opencode serve at http://127.0.0.1:4096 did not answer within 30s — it may have been restarted; retry", - }, - // vite.config.ts, 502 - { - message: - "cannot reach opencode serve at http://127.0.0.1:4096 — start it with `bun openflow.ts` from the repo root", - }, - // the shape server.ts used to emit, in case a host ever drifts back - { error: "cannot reach opencode serve at http://127.0.0.1:4096: fetch failed" }, - ] + test("an empty body renders as the status, never as {}", () => { const response = new Response(null, { status: 502, statusText: "Bad Gateway" }) - for (const body of bodies) { - const rendered = describe(body, response) - expect(rendered).not.toContain("{") - expect(rendered).not.toContain("}") - expect(rendered).toContain("opencode serve") - expect(rendered).toEndWith("(HTTP 502 Bad Gateway)") - } + expect(describe({}, response)).toBe("HTTP 502 Bad Gateway") + expect(describe(undefined, response)).toBe("HTTP 502 Bad Gateway") }) - test("falls back to the status when the body is empty", () => { - const response = new Response(null, { status: 502, statusText: "Bad Gateway" }) - expect(describe({}, response)).toBe("HTTP 502 Bad Gateway") + test("appends the status to a body that does say something", () => { + const response = new Response(null, { status: 503, statusText: "Service Unavailable" }) + expect(describe({ error: "omp is not installed" }, response)).toBe("omp is not installed (HTTP 503 Service Unavailable)") + }) + + test("a successful response adds nothing", () => { + expect(describe(new Error("boom"), new Response(null, { status: 200 }))).toBe("boom") }) - test("names the effect failure tag when that is all the body carries", () => { + test("falls back to the _tag, then to unknown", () => { expect(describe({ _tag: "ProviderAuthError" })).toBe("ProviderAuthError") + expect(describe(undefined)).toBe("unknown error") }) +}) - test("serializes a shape it does not recognise rather than losing it", () => { - expect(describe({ reason: "nope", attempts: 2 })).toBe('{"reason":"nope","attempts":2}') +suite("models", () => { + test("parseModel splits on the first slash, keeping nested ids whole", () => { + expect(parseModel("openrouter/anthropic/claude-opus-5")).toEqual({ + providerID: "openrouter", + id: "anthropic/claude-opus-5", + }) + expect(parseModel("claude")).toBeUndefined() + expect(parseModel("/claude")).toBeUndefined() + expect(parseModel(undefined)).toBeUndefined() }) - test("never renders an empty body as the string {}", () => { - expect(describe({})).toBe("unknown error") - expect(describe(undefined)).toBe("unknown error") + test("formatModel inverts parseModel", () => { + expect(formatModel(parseModel("openai/gpt-5"))).toBe("openai/gpt-5") + expect(formatModel(undefined)).toBe("") + }) + + test("accepts reads the input modalities and lets text-ish files through", () => { + const info = { providerID: "anthropic", id: "x", capabilities: { input: ["text", "image"] } } + expect(accepts(info, "image/png")).toBe(true) + expect(accepts(info, "application/pdf")).toBe(false) + expect(accepts(info, "audio/wav")).toBe(false) + expect(accepts(info, "text/markdown")).toBe(true) + expect(accepts(undefined, "image/png")).toBe(false) }) +}) - test("keeps both the reason and the status when both are known", () => { - const response = new Response(null, { status: 401, statusText: "Unauthorized" }) - expect(describe({ message: "bad key" }, response)).toBe("bad key (HTTP 401 Unauthorized)") +suite("context and catalog over the wire", () => { + test("connect caches the context, disconnect drops it", async () => { + expect(() => client.project()).toThrow("not connected") + const first = await client.connect() + expect(first.context.project).toBe("/tmp/project") + expect(client.project()).toBe("/tmp/project") + await client.connect() + expect(seen.filter((entry) => entry.path === "/flow/api/context")).toHaveLength(1) + client.disconnect() + expect(() => client.project()).toThrow("not connected") }) - test("a 2xx response contributes no status noise", () => { - const response = new Response(null, { status: 200 }) - expect(describe("boom", response)).toBe("boom") + test("health, models, sessions and a transcript read their routes", async () => { + expect((await client.health()).omp.version).toBe("18.12.2") + expect((await client.models())[0].id).toBe("claude-opus-5") + expect((await client.sessions(5))[0].title).toBe("coder") + expect(seen.at(-1)?.search).toBe("?limit=5") + expect(await client.sessionTranscript("ses_1", 7)).toEqual([{ role: "user", text: "hello" }]) + expect(seen.at(-1)?.path).toBe(`${ENGINE_BASE}/sessions/ses_1/transcript`) + expect(seen.at(-1)?.search).toBe("?limit=7") }) - test("unwraps an Error's message", () => { - expect(describe(new Error("network error"))).toBe("network error") + test("steps, calls and the final transcript come back as the wire shapes", async () => { + expect((await client.sessionSteps("ses_1"))[0].messageID).toBe("m1") + expect(await client.sessionCalls("ses_1", 3)).toEqual([{ id: "c1", name: "bash", input: { command: "ls" } }]) + expect(seen.at(-1)?.search).toBe("?limit=3") + finals.set("ses_1", { text: "done" }) + expect(await client.transcript("ses_1")).toEqual({ text: "done" }) }) }) -suite("model refs", () => { - test("splits on the first slash only", () => { - expect(parseModel("opencode/glm-5.2")).toEqual({ providerID: "opencode", id: "glm-5.2" }) - expect(parseModel("openrouter/z-ai/glm-4.6")).toEqual({ providerID: "openrouter", id: "z-ai/glm-4.6" }) +suite("sessions", () => { + test("createSession posts the spec and returns the id", async () => { + const spec = { runId: "r1", nodeId: "n1", card: { key: "coder-n1", role: "coder" }, model: "anthropic/claude-opus-5" } + expect(await client.createSession(spec)).toEqual({ id: "ses_1" }) + expect(seen.at(-1)).toMatchObject({ method: "POST", path: `${ENGINE_BASE}/sessions`, body: spec }) }) - test("rejects values with no provider", () => { - expect(parseModel("/glm-5.2")).toBeUndefined() - expect(parseModel("glm-5.2")).toBeUndefined() - expect(parseModel("")).toBeUndefined() + test("a provider failure keeps its status wording so the engine can classify it", async () => { + const failure = await client + .createSession({ runId: "r1", nodeId: "n1", card: { key: "k", role: "r" }, model: "openrouter/rate-limited" }) + .catch((error: Error) => error) + expect(failure).toBeInstanceOf(Error) + expect((failure as Error).message).toMatch(/HTTP 429 Too Many Requests: slow down/) }) - test("round-trips", () => { - expect(formatModel(parseModel("opencode/glm-5.2"))).toBe("opencode/glm-5.2") - expect(formatModel(undefined)).toBe("") + test("a failed route surfaces the wire's own sentence with the status", async () => { + const failure = await client.transcript("gone").catch((error: Error) => error) + expect((failure as Error).message).toBe("session not found: gone (HTTP 404 Not Found)") + }) + + test("prompt sends text, and only the file fields the wire defines", async () => { + await client.prompt("ses_1", "hi", [{ name: "a.png", url: "data:image/png;base64,AA==", mime: "image/png" }]) + expect(seen.at(-1)).toMatchObject({ + method: "POST", + path: `${ENGINE_BASE}/sessions/ses_1/prompt`, + body: { text: "hi", files: [{ name: "a.png", url: "data:image/png;base64,AA==", mime: "image/png" }] }, + }) + await client.prompt("ses_1", "plain") + expect(seen.at(-1)?.body).toEqual({ text: "plain" }) + }) + + test("permission and question answers go to their routes, ids encoded", async () => { + await client.replyPermission("ses_1", "per/1", "always") + expect(seen.at(-1)).toMatchObject({ path: `${ENGINE_BASE}/sessions/ses_1/permission/per%2F1`, body: { reply: "always" } }) + await client.replyQuestion("ses_1", "q1", [["yes"]]) + expect(seen.at(-1)).toMatchObject({ path: `${ENGINE_BASE}/sessions/ses_1/question/q1`, body: { answers: [["yes"]] } }) + await client.rejectQuestion("ses_1", "q1") + expect(seen.at(-1)).toMatchObject({ method: "POST", path: `${ENGINE_BASE}/sessions/ses_1/question/q1/reject` }) + }) + + test("interrupt never throws, release disposes, releaseRun names the run", async () => { + await client.interrupt("broken") + await client.interrupt("ses_1") + await client.release("ses_1") + expect(seen.at(-1)).toMatchObject({ method: "DELETE", path: `${ENGINE_BASE}/sessions/ses_1` }) + await client.releaseRun("r1") + expect(seen.at(-1)).toMatchObject({ path: `${ENGINE_BASE}/runs/release`, body: { runId: "r1" } }) }) }) -suite("turnText", () => { - const assistant = (...parts: string[]) => ({ - type: "assistant", - content: parts.map((part) => (part === "tool" ? { type: "tool" } : { type: "text", text: part })), +suite("waitForIdle", () => { + test("a completed settle resolves and forwards the timeout", async () => { + waits.set("ses_1", { status: "idle", result: "completed" }) + await client.waitForIdle("ses_1", { timeout: 1234, interval: 500 }) + expect(seen.at(-1)).toMatchObject({ method: "POST", path: `${ENGINE_BASE}/sessions/ses_1/wait`, body: { timeoutMs: 1234 } }) + }) + + test("an error result throws the host's text", async () => { + waits.set("ses_1", { status: "idle", result: "error", error: "Provider request failed with HTTP 401 Unauthorized" }) + await expect(client.waitForIdle("ses_1")).rejects.toThrow("Provider request failed with HTTP 401 Unauthorized") + }) + + test("an abort nobody asked for is an error, one the caller asked for is silent", async () => { + waits.set("ses_1", { status: "idle", result: "aborted" }) + await expect(client.waitForIdle("ses_1")).rejects.toThrow("the session was interrupted") + const controller = new AbortController() + controller.abort() + await client.waitForIdle("ses_1", { signal: controller.signal }) }) - const prompt = (text: string) => ({ type: "user", text, content: [] }) - test("reads the newest assistant message that actually said something", () => { - // Measured shape: a card that ends its turn on tool calls leaves the newest - // messages textless, and the block it wrote sits two messages back. - const page = [assistant("tool"), assistant("tool"), assistant("the block", "tool"), prompt("go")] - expect(turnText(page)?.text).toBe("the block") + test("a result that aborts while the caller's signal fires is silent", async () => { + waits.set("ses_1", { status: "idle", result: "aborted" }) + const controller = new AbortController() + const pending = client.waitForIdle("ses_1", { signal: controller.signal }) + controller.abort() + await pending }) - test("stops at the prompt, so a previous turn's block is never re-read", () => { - // Reading past the prompt would dispatch the same work a second time. - const page = [assistant("tool"), prompt("go again"), assistant("last turn's block")] - expect(turnText(page)?.text).toBe("") + test("timeout and missing throw their sentences", async () => { + waits.set("ses_1", { status: "timeout" }) + await expect(client.waitForIdle("ses_1")).rejects.toThrow("timed out waiting for the session to finish") + await expect(client.waitForIdle("ses_2")).rejects.toThrow("session not found: ses_2") }) - test("tool output does not end the scan", () => { - const page = [assistant("tool"), { type: "user", content: [{ type: "tool_result" }] }, assistant("said it")] - expect(turnText(page)?.text).toBe("said it") + test("aborting the signal ends a long-poll the host is still holding", async () => { + const controller = new AbortController() + const pending = client.waitForIdle("hang", { signal: controller.signal }) + // The request is in flight once the host has seen it. + await waitHeld.promise + controller.abort() + await pending + await waitAborted.promise + }) +}) + +suite("subscribe", () => { + function collect() { + const events: BusEvent[] = [] + return { events, onEvent: (event: BusEvent) => events.push(event) } + } + + test("parses events across chunk boundaries, CRLF and comments, then resolves at stream end", async () => { + eventChunks = [ + ': ping\n\ndata: {"type":"a","data":{"sessionID":"s1"}}\r', + '\n\r\n: ping\r\n\r\ndata: {"ty', + 'pe":"b","data":{"x":1}}\n\ndata:{"type":"c"}\n\n', + // multi-line data joins with a newline, so this is one JSON document + 'data: {"type":"d",\ndata: "data":{"y":2}}\n\n', + "data: not json\n\n", + 'data: {"nope":true}\n\n', + // never terminated by a blank line: discarded, not delivered + 'data: {"type":"unterminated"}\n', + ] + const { events, onEvent } = collect() + await client.subscribe(onEvent, new AbortController().signal) + expect(events.map((event) => event.type)).toEqual(["a", "b", "c", "d"]) + expect(events[0].data).toEqual({ sessionID: "s1" }) + expect(events[1].data).toEqual({ x: 1 }) + expect(events[3].data).toEqual({ y: 2 }) + }) + + test("a CR ending a chunk waits for its LF instead of ending the line twice", async () => { + eventChunks = ['data: {"type":"a"}\r', '\n\r\ndata: {"type":"b"}\r\n\r\n'] + const { events, onEvent } = collect() + await client.subscribe(onEvent, new AbortController().signal) + expect(events.map((event) => event.type)).toEqual(["a", "b"]) + }) + + test("a CRLF split across chunks does not end the event early", async () => { + // Read naively, the bare CR ends the first data line and the LF then reads as a blank line, + // delivering `{"type":"a",` alone. Held back, both lines belong to one event. + eventChunks = ['data: {"type":"a",\r', '\ndata: "data":{"x":1}}\r\n\r\n'] + const { events, onEvent } = collect() + await client.subscribe(onEvent, new AbortController().signal) + expect(events).toEqual([{ type: "a", data: { x: 1 } }]) + }) + + test("resolves when the signal aborts while the stream is held open, and closes the stream", async () => { + hold = true + eventChunks = ['data: {"type":"first"}\n\n'] + const controller = new AbortController() + const first = Promise.withResolvers() + const subscribed = client.subscribe(() => first.resolve(), controller.signal) + await first.promise + controller.abort() + await subscribed + await streamCancelled.promise + }) + + test("an already-aborted signal never opens the stream", async () => { + const controller = new AbortController() + controller.abort() + await client.subscribe(() => {}, controller.signal) + expect(seen.some((entry) => entry.path === `${ENGINE_BASE}/events`)).toBe(false) + }) +}) + +suite("testModel", () => { + test("a model that answers is ok, and the throwaway session is released", async () => { + waits.set("ses_1", { status: "idle", result: "completed" }) + finals.set("ses_1", { text: "ok" }) + const result = await client.testModel("anthropic/claude-opus-5") + expect(result.ok).toBe(true) + const create = seen.find((entry) => entry.method === "POST" && entry.path === `${ENGINE_BASE}/sessions`) + expect(create?.body).toMatchObject({ + runId: "model-test", + card: { key: "openflow-model-test" }, + model: "anthropic/claude-opus-5", + }) + expect(seen.find((entry) => entry.path.endsWith("/prompt"))?.body).toEqual({ text: "Reply with the single word ok." }) + expect(seen.at(-1)).toMatchObject({ method: "DELETE", path: `${ENGINE_BASE}/sessions/ses_1` }) }) - test("joins every text part of the message it settles on", () => { - expect(turnText([assistant("one", "two")])?.text).toBe("one\ntwo") + test("a provider error in the transcript is the failure, and the session is still released", async () => { + waits.set("ses_1", { status: "idle", result: "completed" }) + finals.set("ses_1", { text: "", error: "HTTP 401 Unauthorized" }) + const result = await client.testModel("openai/gpt-5") + expect(result).toMatchObject({ ok: false, error: "HTTP 401 Unauthorized" }) + expect(seen.at(-1)?.method).toBe("DELETE") }) - test("an error on the newest message survives a textless turn", () => { - const page = [{ type: "assistant", content: [{ type: "tool" }], error: { message: "boom" } }] - expect(turnText(page)).toEqual({ text: "", error: { message: "boom" } }) + test("an empty answer is not ok", async () => { + waits.set("ses_1", { status: "idle", result: "completed" }) + expect(await client.testModel("openai/gpt-5")).toMatchObject({ ok: false, error: "the model returned no text" }) }) - test("nothing to read at all", () => { - expect(turnText([prompt("go")])).toBeUndefined() - expect(turnText([])).toBeUndefined() + test("a session that cannot be created reports the host's error and releases nothing", async () => { + const result = await client.testModel("openrouter/rate-limited") + expect(result.ok).toBe(false) + expect(result.error).toMatch(/HTTP 429/) + expect(seen.some((entry) => entry.method === "DELETE")).toBe(false) }) }) diff --git a/packages/flow/src/server/client.ts b/packages/flow/src/server/client.ts index f62450c68c..4ef78ea48d 100644 --- a/packages/flow/src/server/client.ts +++ b/packages/flow/src/server/client.ts @@ -1,9 +1,36 @@ -import { createOpencodeClient, type AgentV2Info, type ModelRef, type ModelV2Info } from "@opencode-ai/sdk/v2/client" -import type { Attachment, StepUsage } from "../graph/types" -import { sessionRows, transcriptTurns, type SessionRow, type Turn } from "./sessions" +import type { Attachment } from "../graph/types" +import { engineRoutes, sessionRoute } from "../../shared/engine" +import type { + BusEvent, + CreateSession, + HealthInfo, + ModelInfo, + PermissionReply, + PromptFile, + PromptRequest, + QuestionInfo, + SessionRow, + ToolCall, + Transcript, + Turn, + WaitRequest, + WaitResult, + WireStep, +} from "../../shared/engine" export type { Attachment } -export type { SessionRow, Turn } +export type { + BusEvent, + HealthInfo, + ModelInfo, + PermissionReply, + QuestionInfo, + SessionRow, + ToolCall, + Transcript, + Turn, + WireStep, +} export type FlowContext = { project: string @@ -14,26 +41,27 @@ export type FlowContext = { pipeline?: string } -export type OpencodeClient = ReturnType +export type ModelRef = { providerID: string; id: string } let context: FlowContext | undefined -let client: OpencodeClient | undefined -/** Same server, no directory — see `agentsUnscoped`. Never rebuilt, since it - * carries no project to go stale. */ -let unscoped: OpencodeClient | undefined - /** - * The vite dev server proxies `/api`, `/global` and `/event` to the running - * `opencode serve`, so the browser talks to the same origin and no CORS or - * password plumbing is needed. + * Prefix of every request. Empty in the browser, where the page's own origin + * serves `/flow/api/*`; tests point it at a loopback server, since a relative + * URL means nothing outside a page. */ +let origin = "" + +export function setOrigin(value: string) { + origin = value + context = undefined +} + export async function connect() { - if (client && context) return { client, context } - const response = await fetch("/flow/api/context") + if (context) return { context } + const response = await fetch(`${origin}/flow/api/context`) if (!response.ok) throw new Error(`flow store unavailable (${response.status})`) context = (await response.json()) as FlowContext - client = createOpencodeClient({ baseUrl: window.location.origin, directory: context.project }) - return { client, context } + return { context } } export function project() { @@ -42,31 +70,24 @@ export function project() { } /** - * Drops the cached client and context so the next call to `connect()` rereads - * `/flow/api/context` and rebuilds the client against whatever project is - * live there. Used after switching the project — every session created - * before this still points at the old directory, but nothing new does. + * Drops the cached context so the next call to `connect()` rereads + * `/flow/api/context` and sees whatever project is live there. Used after + * switching the project — every session created before this still points at + * the old directory, but nothing new does. */ export function disconnect() { - client = undefined context = undefined } -function unwrap(result: { data?: T; error?: unknown; response?: Response }): T { - if (result.error) throw new Error(describe(result.error, result.response)) - if (result.response && !result.response.ok) throw new Error(describe(undefined, result.response)) - if (result.data === undefined) throw new Error("empty response") - return result.data -} - /** - * Turns whatever the client produced into a line a user can act on. + * Turns whatever a failure produced into a line a user can act on. * - * The generated client hands back the *parsed body* on the result-tuple path, - * so a failure with no body — the dev proxy's answer when `opencode serve` is - * down — arrives as `{}` and used to render as the literal string `{}`. The - * status is the only fact left in that case, so it is carried separately and - * used whenever the body says nothing. + * A body can be empty: the dev proxy answers `{}` or nothing when the host is + * down, and that used to render as the literal string `{}`. The status is the + * only fact left in that case, so it is carried separately and used whenever + * the body says nothing. The engine wire's own bodies are `{ error }` + * (`EngineError`); provider failures keep their `HTTP 429 …` wording in that + * text because the engine classifies retries by regex on it. */ export function describe(error: unknown, response?: Response): string { const status = @@ -85,92 +106,44 @@ function reason(error: unknown): string | undefined { const record = error as Record if (typeof record.message === "string" && record.message) return record.message if (typeof record.data?.message === "string" && record.data.message) return record.data.message - // Both hosts answer a proxy failure with `message`, but `error` is the key - // the `/flow/api` store uses and the one the built host used to emit. Reading - // it too means a host drifting back to it renders a sentence, not a JSON blob. + // `error` is the key `EngineError` and the `/flow/api` store answer with; a + // host drifting to another shape should still render a sentence, not a blob. if (typeof record.error === "string" && record.error) return record.error if (typeof record._tag === "string" && record._tag) return record._tag const json = JSON.stringify(error) return json && json !== "{}" && json !== "[]" ? json : undefined } -export async function health() { - const { client } = await connect() - const result = await client.v2.health.get() - return unwrap(result as any) -} - -export async function agents(): Promise { - const { client } = await connect() - const body = unwrap((await client.v2.agent.list()) as any) - return (body.data ?? body) as AgentV2Info[] -} - -/** - * The agents a *run* will actually have, rather than the ones this directory - * reports. - * - * `agents()` is scoped to `OPENFLOW_PROJECT`, and a directory the server does - * not recognise as a project — most often one that is simply not a git - * repository — answers with an empty list, built-ins included. A session's - * config is resolved from the engine's own cwd and not from that directory, so - * a second client with no `directory` at all sees what the drain sees, and an - * empty scoped list can be told apart from an agent that is genuinely absent. - */ -export async function agentsUnscoped(): Promise { - unscoped ??= createOpencodeClient({ baseUrl: window.location.origin }) - const body = unwrap((await unscoped.v2.agent.list()) as any) - return (body.data ?? body) as AgentV2Info[] -} - -export async function models(): Promise { - const { client } = await connect() - const body = unwrap((await client.v2.model.list()) as any) - return (body.data ?? body) as ModelV2Info[] -} - -/** - * One provider the server knows about, with whatever credentials it currently - * holds for it. - * - * The catalog carries all ~184 models.dev providers whether or not they are - * usable; `connections` is what decides. A provider with no connection and no - * environment variable contributes no models to `models()`, which is why an - * un-keyed OpenFlow only ever sees the free zen models. - */ -export type Integration = { - id: string - name: string - methods: Array<{ type: string; names?: string[] }> - connections: Array<{ type: string; id: string; label?: string }> +/** A non-2xx answer as an Error whose text is the wire's own, status appended. */ +async function failure(response: Response) { + const text = await response.text().catch(() => "") + let body: unknown + try { + body = JSON.parse(text) + } catch { + // Not JSON (an HTML error page from a proxy): the status is all there is. + } + return new Error(describe(body, response)) } -export async function integrations(): Promise { - const { client } = await connect() - const body = unwrap((await client.v2.integration.list()) as any) - return (body.data ?? body) as Integration[] +async function call(path: string, init: { method?: string; body?: unknown; signal?: AbortSignal } = {}): Promise { + const response = await fetch(`${origin}${path}`, { + method: init.method ?? (init.body === undefined ? "GET" : "POST"), + headers: init.body === undefined ? undefined : { "content-type": "application/json" }, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + signal: init.signal, + }) + if (!response.ok) throw await failure(response) + const text = await response.text() + return (text ? JSON.parse(text) : undefined) as T } -/** - * Stores an API key for a provider and makes its models selectable. - * - * The catalog picks the credential up immediately — no `opencode serve` - * restart, unlike a config merge. The key is NOT verified here: the server - * stores whatever it is given, so a typo surfaces later as a 401 at run time. - * Use `testModel` to find that out now instead. - */ -export async function connectKey(integrationID: string, key: string, label?: string) { - const { client } = await connect() - const result = (await client.v2.integration.connect.key({ integrationID, key, label })) as any - if (result.error) throw new Error(describe(result.error)) - return true +export async function health() { + return call(engineRoutes.health) } -export async function removeCredential(credentialID: string) { - const { client } = await connect() - const result = (await client.v2.credential.remove({ credentialID })) as any - if (result.error) throw new Error(describe(result.error)) - return true +export async function models(): Promise { + return call(engineRoutes.models) } export type ModelTest = { ok: boolean; error?: string; ms: number } @@ -178,24 +151,33 @@ export type ModelTest = { ok: boolean; error?: string; ms: number } /** * Proves a model actually answers, by running one throwaway session against it. * - * A stored key and a listed model are both necessary and neither is - * sufficient: the catalog advertises models an account may not be entitled to - * (`north-mini-code-free` answers `HTTP 401 Model ... is not supported`), and - * a mistyped key looks identical until something is sent. This spends a few + * A listed model and stored credentials are both necessary and neither is + * sufficient: a catalog advertises models an account may not be entitled to, + * and a mistyped key looks identical until something is sent. This spends a few * tokens on purpose. */ export async function testModel(model: string, options: { timeout?: number } = {}): Promise { const started = Date.now() + let sessionID: string | undefined try { - const session = await createSession({ model }) - await prompt(session.id, "Reply with the single word: ok") - await waitForIdle(session.id, { timeout: options.timeout ?? 60_000, interval: 500 }) + const session = await createSession({ + runId: "model-test", + nodeId: "model-test", + card: { key: "openflow-model-test", role: "model test" }, + model, + permissions: "auto", + }) + sessionID = session.id + await prompt(session.id, "Reply with the single word ok.") + await waitForIdle(session.id, { timeout: options.timeout ?? 60_000 }) const result = await transcript(session.id) if (result.error) return { ok: false, error: result.error, ms: Date.now() - started } if (!result.text) return { ok: false, error: "the model returned no text", ms: Date.now() - started } return { ok: true, ms: Date.now() - started } } catch (error) { return { ok: false, error: describe(error), ms: Date.now() - started } + } finally { + if (sessionID) await release(sessionID) } } @@ -211,75 +193,38 @@ export function formatModel(model?: ModelRef) { } /** - * `directory` places the session somewhere other than the project — a card's - * own git worktree. Measured 2026-09-03: a session created with that location - * ran its `bash` tool there rather than in the engine's cwd, which is what - * makes per-card isolation possible at all. It is fixed for the life of the - * session, so a card re-dispatched into the session it already holds cannot be - * moved to a different tree. + * The host launches an Omp process for the card (`input.directory` is its + * worktree, fixed for the session's life) or, with `input.reuse`, re-attaches to + * one an earlier run left open. */ -export async function createSession(input: { agent?: string; model?: string; directory?: string }) { - const { client, context } = await connect() - const body = unwrap( - (await client.v2.session.create({ - agent: input.agent || undefined, - model: parseModel(input.model), - location: { directory: input.directory || context.project }, - })) as any, - ) - const session = body.data ?? body - return session as { id: string } +export async function createSession(input: CreateSession): Promise<{ id: string }> { + return call<{ id: string }>(engineRoutes.sessions, { method: "POST", body: input }) } /** One page of history — enough that the sidebar filter has everything to match. */ const SESSION_PAGE = 200 -/** - * The sessions `opencode serve` holds for this project, newest first. - * - * These are the rows of the drizzle-backed `session` table in `opencode.db`, - * the same ones the CLI and TUI list: a node's session is created through the - * server, so it is already there and OpenFlow keeps no second store. - * - * `search` is deliberately *not* forwarded to the endpoint. The server matches - * it against the session title only, and an OpenFlow node never sets a title — - * every one of them is auto-named "New session - ". So `search=coder` - * answers zero for a project holding ten coder sessions, while the fields that - * actually identify a node (its generated agent, its model) are unsearchable. - * Filtering a page here matches what the user can see; see `matches()`. - */ +/** The sessions the host holds, newest first. The filtering happens in the page: see `matches()`. */ export async function sessions(limit = SESSION_PAGE): Promise { - const { client, context } = await connect() - const body = unwrap( - (await client.v2.session.list({ directory: context.project, order: "desc", limit })) as any, - ) - return sessionRows(body.data ?? []) + return call(`${engineRoutes.sessions}?limit=${limit}`) } /** A session's readable turns, oldest first, for the sidebar's detail view. */ export async function sessionTranscript(sessionID: string, limit = 100): Promise { - const { client } = await connect() - const body = unwrap((await client.v2.session.messages({ sessionID, order: "asc", limit })) as any) - return transcriptTurns(body.data ?? []) + return call(`${sessionRoute(sessionID, "/transcript")}?limit=${limit}`) } /** * Sends the prompt, with any attachments as `files`. * - * The server reads the mime type straight off a `data:` URL and passes the URL - * through to the model as media, so a data URL is the whole transport — nothing - * is written to disk and no upload endpoint is involved. Attachments are only - * ever passed for a model that accepts that modality; see `accepts()`. + * A `data:` URL is the whole transport: nothing is written to disk and no + * upload endpoint is involved. Attachments are only ever passed for a model + * that accepts that modality; see `accepts()`. */ -export async function prompt(sessionID: string, text: string, files: Attachment[] = []) { - const { client } = await connect() - const attachments = files.map((file) => ({ uri: file.url, name: file.name })) - return unwrap( - (await client.v2.session.prompt({ - sessionID, - prompt: { text, ...(attachments.length ? { files: attachments } : {}) }, - })) as any, - ) +export async function prompt(sessionID: string, text: string, files: PromptFile[] = []) { + const body: PromptRequest = { text } + if (files.length) body.files = files.map((file) => ({ name: file.name, url: file.url, mime: file.mime })) + return call(sessionRoute(sessionID, "/prompt"), { method: "POST", body }) } /** @@ -290,7 +235,7 @@ export async function prompt(sessionID: string, text: string, files: Attachment[ * request would either error or, worse, silently drop it — so the engine * substitutes a text note naming what was withheld and the chain keeps going. */ -export function accepts(model: ModelV2Info | undefined, mime: string) { +export function accepts(model: ModelInfo | undefined, mime: string) { if (!model) return false const modalities = new Set(model.capabilities?.input ?? []) if (mime.startsWith("image/")) return modalities.has("image") @@ -301,305 +246,159 @@ export function accepts(model: ModelV2Info | undefined, mime: string) { return true } -/** Session IDs whose agent loop is currently executing on the server. */ -export async function activeSessions(): Promise> { - const { client } = await connect() - const body = unwrap((await client.v2.session.active()) as any) - return new Set(Object.keys(body.data ?? body ?? {})) -} - /** - * Resolves when the session's agent loop goes idle. + * Resolves when the host reports the session settled — a long-poll, so there + * is no polling interval; `options.interval` is accepted for the engine's call + * sites and ignored. * - * `POST /api/session/:id/wait` answers 503 "Session wait is not available yet" - * on this server build, so idleness is derived from `/api/session/active` - * (which lists executing sessions) plus a finished assistant turn. + * An abort of `options.signal` ends the request and returns silently, the same + * as an `aborted` result that the caller asked for. An `aborted` result nobody + * asked for (something else interrupted the session) is an error: the engine + * must not read it as a finished answer. */ export async function waitForIdle( sessionID: string, options: { signal?: AbortSignal; interval?: number; timeout?: number } = {}, ) { - const interval = options.interval ?? 750 - const timeout = options.timeout ?? 30 * 60_000 - const started = Date.now() - let sawActive = false - - while (true) { + if (options.signal?.aborted) return + const body: WaitRequest = { timeoutMs: options.timeout } + let result: WaitResult + try { + result = await call(sessionRoute(sessionID, "/wait"), { method: "POST", body, signal: options.signal }) + } catch (error) { if (options.signal?.aborted) return - await new Promise((resolve) => setTimeout(resolve, interval)) + throw error + } + if (result.status === "missing") throw new Error(`session not found: ${sessionID}`) + if (result.status === "timeout") throw new Error("timed out waiting for the session to finish") + if (result.result === "error") throw new Error(result.error || "the session failed") + if (result.result === "aborted") { if (options.signal?.aborted) return - - const active = await activeSessions().catch(() => undefined) - if (active?.has(sessionID)) { - sawActive = true - continue - } - - const finished = await lastAssistant(sessionID) - if (finished && (finished.finish || finished.error)) return - // The loop may not have been admitted yet; give it a grace window. - if (!sawActive && Date.now() - started < 20_000) continue - if (Date.now() - started > timeout) throw new Error("timed out waiting for the session to finish") - if (active === undefined) continue - if (!sawActive) throw new Error("the session never started executing — check the model and provider auth") - return + throw new Error("the session was interrupted") } } -async function lastAssistant(sessionID: string) { - const { client } = await connect() - const body = unwrap((await client.v2.session.messages({ sessionID, order: "desc", limit: 10 })) as any) - return (body.data ?? []).find((message: any) => message.type === "assistant") -} - -export type PermissionReply = "once" | "always" | "reject" - -/** - * Answers a pending permission request. - * - * "once" approves just this call; "always" writes the approval into the - * project's saved-permission store, which outlives the run. - */ +/** Answers a pending permission request (`permission.v2.asked`). */ export async function replyPermission(sessionID: string, requestID: string, reply: PermissionReply) { - const { client } = await connect() - const result = (await client.v2.session.permission.reply({ sessionID, requestID, reply })) as any - if (result.error) throw new Error(describe(result.error)) - return result.data -} - -export type QuestionOption = { label: string; description: string } -export type QuestionInfo = { - question: string - header: string - options: QuestionOption[] - multiple?: boolean - custom?: boolean + return call(sessionRoute(sessionID, `/permission/${encodeURIComponent(requestID)}`), { + method: "POST", + body: { reply }, + }) } /** - * Answers a question the agent asked through its `question` tool. + * Answers a question the agent asked through omp's `ask` tool. * - * Answers are positional — one array of chosen labels per question, in the - * order they were asked — and a custom answer is just a label the options did - * not contain. + * `answers` has one entry per question, each the labels chosen — or the typed + * text for a custom answer. */ export async function replyQuestion(sessionID: string, requestID: string, answers: string[][]) { - const { client } = await connect() - const result = (await client.v2.session.question.reply({ - sessionID, - requestID, - questionV2Reply: { answers }, - })) as any - if (result.error) throw new Error(describe(result.error)) - return result.data + return call(sessionRoute(sessionID, `/question/${encodeURIComponent(requestID)}`), { + method: "POST", + body: { answers }, + }) } /** Declines to answer. The agent is told nobody answered and continues on its own. */ export async function rejectQuestion(sessionID: string, requestID: string) { - const { client } = await connect() - const result = (await client.v2.session.question.reject({ sessionID, requestID })) as any - if (result.error) throw new Error(describe(result.error)) - return result.data + return call(sessionRoute(sessionID, `/question/${encodeURIComponent(requestID)}/reject`), { method: "POST" }) } -export type McpStatus = - | { status: "connected" } - | { status: "disabled" } - | { status: "failed"; error: string } - | { status: "needs_auth" } - | { status: "needs_client_registration"; error: string } - -/** - * Live connection state of every MCP server the running server knows about. - * - * This is the server's view, not the config's: a server added to - * `opencode.json` after `opencode serve` booted is absent here until it - * restarts, which is the single most confusing thing about MCP config and the - * reason the panel prints both lists. - */ -export async function mcpStatus(): Promise> { - const { client } = await connect() - const body = unwrap((await client.mcp.status({})) as any) - return (body?.data ?? body ?? {}) as Record -} - -export async function mcpConnect(name: string) { - const { client } = await connect() - const result = (await client.mcp.connect({ name })) as any - if (result.error) throw new Error(describe(result.error)) - return result.data +/** Stops the turn in flight. Never throws: an interrupt that cannot be delivered has nothing left to stop. */ +export async function interrupt(sessionID: string) { + await call(sessionRoute(sessionID, "/interrupt"), { method: "POST" }).catch(() => undefined) } -export async function mcpDisconnect(name: string) { - const { client } = await connect() - const result = (await client.mcp.disconnect({ name })) as any - if (result.error) throw new Error(describe(result.error)) - return result.data +/** Disposes a session the engine abandons. Best effort: a session already gone is the goal state. */ +export async function release(sessionID: string) { + await call(sessionRoute(sessionID), { method: "DELETE" }).catch(() => undefined) } -export async function interrupt(sessionID: string) { - const { client } = await connect() - await client.v2.session.interrupt({ sessionID }).catch(() => undefined) +/** Disposes every session of a run that finished or was abandoned. Best effort. */ +export async function releaseRun(runId: string) { + await call(engineRoutes.releaseRun, { method: "POST", body: { runId } }).catch(() => undefined) } -export type Transcript = { text: string; error?: string } - -/** Final assistant turn of a session: concatenated text parts plus any error. */ +/** Final assistant turn of a session: the joined text parts plus any error, already described. */ export async function transcript(sessionID: string): Promise { - const { client } = await connect() - const body = unwrap((await client.v2.session.messages({ sessionID, order: "desc", limit: 30 })) as any) - const messages: any[] = body.data ?? [] - - const found = turnText(messages) - if (!found) return { text: "" } - return { text: found.text, error: found.error ? describe(found.error) : undefined } -} - -/** - * What a card said on its most recent turn, from the message page newest first. - * - * A turn is a *run* of assistant messages and only some of them carry text: a - * card that ends on a tool call leaves `content: [tool]` as its newest message, - * with what it actually said one or two messages behind it. Reading only the - * newest returns "" for those turns — measured, that is what killed three - * orchestration runs with "your message carried no block" while the block sat - * in the same turn, two messages back. - * - * The scan stops at a user message carrying a top-level `text`, which is what a - * real prompt looks like. Tool output arrives as parts on an assistant message - * rather than as a prompt, so it cannot end the scan early — and stopping at the - * prompt is what keeps a *previous* turn's block from being read as this turn's - * answer, which would dispatch the same work twice. - * - * Errors belong to the newest message whether or not it said anything: a turn - * that failed after speaking is still a failed turn. - */ -export function turnText(messages: any[]) { - let newest: any - for (const message of messages) { - if (message.type === "user" && typeof message.text === "string" && message.text.trim()) break - if (message.type !== "assistant") continue - newest ??= message - const text = (message.content ?? []) - .filter((part: any) => part.type === "text") - .map((part: any) => part.text) - .join("\n") - .trim() - if (text) return { text, error: newest.error } - } - return newest ? { text: "", error: newest.error } : undefined + return call(sessionRoute(sessionID, "/final")) } -export type ToolCall = { id: string; name: string; input: unknown } - /** - * Tool calls the session made, newest first, each with the id opencode gave it. - * - * The id is what lets the caller tell this turn's calls from earlier ones. An - * earlier attempt bounded the scan at the most recent user message instead, and - * it silently returned nothing: a tool *result* comes back as a user-type - * message, so the scan stopped before it ever reached the assistant message - * holding the call. Measured — the card called the tool correctly and the - * engine read its text anyway. + * Tool calls the session made, newest first, each with the id omp gave it. * - * Read from the message history rather than the event bus for the same reason - * usage is: the bus is best-effort and reconnects, while this is what the - * server persisted. It is also why the orchestrator's decision is read here - * rather than from `transcript` — a card that dispatches and then writes a - * to-do list has the call sitting under later parts, and `transcript` only ever - * returns the newest assistant message's *text*. - * - * One page is enough: the caller wants the decision this turn ended on, not the - * history of every turn before it. + * The id is what lets the caller tell this turn's calls from earlier ones. + * Read from the host's persisted history rather than the event bus, which is + * best-effort and has no replay. */ export async function sessionCalls(sessionID: string, limit = 30): Promise { - const { client } = await connect() - const body = unwrap((await client.v2.session.messages({ sessionID, order: "desc", limit })) as any) - const calls: ToolCall[] = [] - for (const message of (body.data ?? []) as any[]) { - if (message.type !== "assistant") continue - // Messages arrive newest-first but parts within one are in the order they - // happened, so they are walked backwards to keep the whole list newest-first. - for (const part of [...((message.content ?? []) as any[])].reverse()) { - if (part.type !== "tool" || typeof part.name !== "string" || typeof part.id !== "string") continue - calls.push({ id: part.id, name: part.name, input: part.state?.input }) - } - } - return calls + return call(`${sessionRoute(sessionID, "/calls")}?limit=${limit}`) } /** * Every settled assistant step in a session, with the tokens the provider - * reported for it. - * - * This is the authoritative usage record: the event bus can drop events (it is - * subscribed best-effort and reconnects), while the message history is what the - * server persisted. The whole history is paged through — a session that - * compacted or ran fifty tool steps has far more than one page of messages, and - * a short read would silently under-report the bill. - * - * Steps with no `tokens` are skipped rather than counted as zero: a step that - * failed before the provider returned usage may still have been billed, and - * inventing a zero for it would state a total we cannot prove. + * billed for it. Read from the host rather than summed from bus events, for the + * same reason as `sessionCalls`. */ -export async function sessionSteps(sessionID: string): Promise { - const { client } = await connect() - const steps: StepUsage[] = [] - const seen = new Set() - let cursor: string | undefined - for (let page = 0; page < 100; page += 1) { - const body = unwrap( - (await client.v2.session.messages({ - sessionID, - limit: 200, - ...(cursor ? { cursor } : { order: "asc" }), - })) as any, - ) - const messages: any[] = body.data ?? [] - for (const message of messages) { - if (message.type !== "assistant" || !message.tokens) continue - if (seen.has(message.id)) continue - seen.add(message.id) - steps.push({ - messageID: message.id, - model: formatModel(message.model), - tokens: { - input: message.tokens.input ?? 0, - output: message.tokens.output ?? 0, - reasoning: message.tokens.reasoning ?? 0, - cacheRead: message.tokens.cache?.read ?? 0, - cacheWrite: message.tokens.cache?.write ?? 0, - }, - }) - } - cursor = messages.length === 200 ? body.cursor?.next : undefined - if (!cursor) break - } - return steps +export async function sessionSteps(sessionID: string): Promise { + return call(sessionRoute(sessionID, "/steps")) } -export type BusEvent = { type: string; data?: Record } - /** - * Subscribes to the global event bus. Resolves when the stream ends or the - * signal aborts — the SSE client reconnects on error, so the abort has to be - * raced rather than awaited, otherwise this never settles. + * Subscribes to the event stream. Resolves when the stream ends or the signal + * aborts; a dropped connection rejects instead of reconnecting, because there + * is no replay and the engine re-reads authoritative state itself after a gap. + * + * SSE framing: events are `data:` lines ended by a blank line, `:` lines are + * keep-alive comments, and any of CRLF / LF / CR ends a line — including when a + * chunk boundary falls between the CR and the LF. */ export async function subscribe(onEvent: (event: BusEvent) => void, signal: AbortSignal) { - const { client } = await connect() - const result: any = await client.v2.event.subscribe({ signal, sseMaxRetryAttempts: 0 } as any) - const consume = (async () => { - for await (const event of result.stream) { - if (signal.aborted) return - if (event && typeof event === "object" && "type" in event) onEvent(event as BusEvent) + if (signal.aborted) return + try { + const response = await fetch(`${origin}${engineRoutes.events}`, { headers: { accept: "text/event-stream" }, signal }) + if (!response.ok) throw await failure(response) + if (!response.body) throw new Error("the event stream has no body") + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = "" + let data: string[] = [] + + const line = (text: string) => { + if (text === "") { + const joined = data.join("\n") + data = [] + if (!joined) return + let event: unknown + try { + event = JSON.parse(joined) + } catch { + // A frame the host did not finish writing is not an event. + return + } + if (event && typeof event === "object" && typeof (event as BusEvent).type === "string") onEvent(event as BusEvent) + return + } + if (!text.startsWith("data:")) return + data.push(text.slice(text.startsWith("data: ") ? 6 : 5)) } - })() - await Promise.race([consume, aborted(signal)]) -} -function aborted(signal: AbortSignal) { - return new Promise((resolve) => { - if (signal.aborted) return resolve() - signal.addEventListener("abort", () => resolve(), { once: true }) - }) + while (true) { + const chunk = await reader.read() + if (signal.aborted) return + buffer += decoder.decode(chunk.value, { stream: !chunk.done }) + let start = 0 + for (const match of buffer.matchAll(/\r\n|\r|\n/g)) { + // A CR at the very end may be the first half of a CRLF split across chunks. + if (match[0] === "\r" && match.index + 1 === buffer.length && !chunk.done) break + line(buffer.slice(start, match.index)) + start = match.index + match[0].length + } + buffer = buffer.slice(start) + if (chunk.done) return + } + } catch (error) { + if (signal.aborted) return + throw error + } } diff --git a/packages/flow/src/server/engine.test.ts b/packages/flow/src/server/engine.test.ts index d3b36d8130..ac87612ca7 100644 --- a/packages/flow/src/server/engine.test.ts +++ b/packages/flow/src/server/engine.test.ts @@ -1,23 +1,34 @@ import { describe, expect, test } from "bun:test" import { pipeline } from "../graph/test-support" +import type { + BusEvent, + CreateSession, + EngineApi, + ModelInfo, + PermissionReply, + PromptFile, + QuestionInfo, +} from "../../shared/engine" +import type { MemoryReadRequest, MemoryReadResult, MemoryWriteRequest, MemoryWriteResult } from "../../shared/memory" +import type { UpliftRequest, UpliftResult } from "../../shared/uplift" import type { Attachment, NodeEvent, Pipeline, RunLog, StepUsage } from "../graph/types" -import type { BusEvent, PermissionReply, QuestionInfo } from "./client" import { DEFAULT_NODE_TIMEOUT, start, type EngineDeps, type EngineHooks, + type NodePatch, type PermissionRequest, type QuestionRequest, type RunOptions, + type UpliftFailureRequest, } from "./engine" /** - * The engine is exercised against a fake server client. Everything the real one + * The engine is exercised against a fake `EngineApi`. Everything the real client * does over HTTP — sessions, prompts, the idle wait, the event bus, permission * replies — is stubbed here, so these tests cover layering, context piping, - * failure containment, stop and the permission policy without a running - * `opencode serve`. + * failure containment, stop and the permission policy without a running host. */ type Behavior = { @@ -62,14 +73,16 @@ type HarnessOptions = { * `/api/health` before its catalog has filled, so the first read can be short. */ modelsRefilled?: string[] - /** Agent ids the server knows about for this project. */ - agents?: string[] - /** - * Agent ids the server knows about with no directory on the read — what the - * drain sees. Omitted means the host has no unscoped read at all, which is - * how an older build and a plain test double both look. - */ - unscoped?: string[] + /** Prompt Uplift. Omitted means the engine has none, as a host without the planner would. */ + uplift?: (request: UpliftRequest) => Promise | UpliftResult + /** Shared memory. Omitted means the engine has none. */ + memory?: { + read?: (request: MemoryReadRequest) => Promise | MemoryReadResult + write?: (request: MemoryWriteRequest) => Promise | MemoryWriteResult + } + onUpliftFailure?: (request: UpliftFailureRequest) => Promise<"original" | "retry" | "cancel"> + /** Whether the host lets the engine hand sessions back (`EngineApi.release`). */ + release?: boolean /** Subset of `models` that accepts image input. */ vision?: string[] /** Price rows per "providerID/id", as the catalog would report them. */ @@ -139,11 +152,19 @@ function harness(options: HarnessOptions = {}) { const closed: string[] = [] const waits: { node: string; timeout?: number }[] = [] const notices: { kind: string; text: string }[] = [] + const patches: { id: string; patch: NodePatch }[] = [] const saved: RunLog[] = [] let deliver: (event: BusEvent) => void = () => {} /** sessionID -> the directory it was created in, for the worktree tests. */ const sessionDirs = new Map() const worktreeCalls: { open: string[][]; merged: number; cleaned: number } = { open: [], merged: 0, cleaned: 0 } + /** Everything the engine asked a session to be created with, in order. */ + const creations: CreateSession[] = [] + /** Sessions handed back to the host, in order. */ + const released: string[] = [] + const upliftRequests: UpliftRequest[] = [] + const memoryReads: MemoryReadRequest[] = [] + const memoryWrites: MemoryWriteRequest[] = [] let catalogReads = 0 let treeReads = 0 const checkpointed: number[] = [] @@ -153,7 +174,7 @@ function harness(options: HarnessOptions = {}) { for (const [id, spec] of Object.entries(behavior)) if (spec.hold) gates.set(id, deferred()) - const api: EngineDeps["api"] = { + const api: EngineApi = { async subscribe(onEvent, signal) { deliver = onEvent await new Promise((resolve) => { @@ -161,17 +182,19 @@ function harness(options: HarnessOptions = {}) { signal.addEventListener("abort", () => resolve(), { once: true }) }) }, - async createSession(input: { directory?: string } = {}) { - const id = `s${++created}` + async createSession(input: CreateSession) { + creations.push(input) + // Re-attaching is not creating: the session was already there. + const id = input.reuse ?? `s${++created}` sessionDirs.set(id, input.directory) return { id } }, - async prompt(sessionID: string, text: string, files: Attachment[] = []) { + async prompt(sessionID: string, text: string, files: PromptFile[] = []) { const node = nodeOf.get(sessionID)! dispatched.push(node) prompts.set(node, text) promptLog.push({ node, text }) - sent.set(node, files) + sent.set(node, files as Attachment[]) inflight++ peak = Math.max(peak, inflight) return {} @@ -222,6 +245,13 @@ function harness(options: HarnessOptions = {}) { replies.push({ sessionID, requestID, reply }) return {} }, + ...(options.release + ? { + async release(sessionID: string) { + released.push(sessionID) + }, + } + : {}), async models() { const list = catalogReads++ && options.modelsRefilled ? options.modelsRefilled : (options.models ?? []) return list.map((value) => { @@ -232,18 +262,8 @@ function harness(options: HarnessOptions = {}) { capabilities: { input: options.vision?.includes(value) ? ["text", "image"] : ["text"] }, cost: options.prices?.[value] ?? [], } - }) as any - }, - async agents() { - return (options.agents ?? []).map((id) => ({ id })) as any + }) as ModelInfo[] }, - ...(options.unscoped - ? { - async agentsUnscoped() { - return options.unscoped!.map((id) => ({ id })) as any - }, - } - : {}), async sessionCalls(sessionID: string) { const node = nodeOf.get(sessionID)! const spec = behavior[node] ?? {} @@ -281,6 +301,7 @@ function harness(options: HarnessOptions = {}) { activity.push({ node: id, event }) }, onNode(id, patch) { + patches.push({ id, patch }) if (patch.sessionID) { nodeOf.set(patch.sessionID, id) sessionOf.set(id, patch.sessionID) @@ -292,6 +313,7 @@ function harness(options: HarnessOptions = {}) { onPermission: options.onPermission, onQuestion: options.onQuestion, onQuestionClosed: (requestID) => closed.push(requestID), + onUpliftFailure: options.onUpliftFailure, } const deps: EngineDeps = { @@ -300,6 +322,32 @@ function harness(options: HarnessOptions = {}) { saved.push(structuredClone(log)) return {} }, + ...(options.uplift + ? { + async uplift(request: UpliftRequest) { + upliftRequests.push(request) + return options.uplift!(request) + }, + } + : {}), + ...(options.memory + ? { + memory: { + async read(request: MemoryReadRequest) { + memoryReads.push(request) + return options.memory!.read + ? options.memory!.read(request) + : { status: "empty" as const, block: "", items: [], scopes: [], elapsedMs: 0 } + }, + async write(request: MemoryWriteRequest) { + memoryWrites.push(request) + return options.memory!.write + ? options.memory!.write(request) + : { status: "stored" as const, subject: `openflow/${request.nodeId}` } + }, + }, + } + : {}), ...(options.scratch ? { async scratch() { @@ -357,6 +405,11 @@ function harness(options: HarnessOptions = {}) { deps, hooks, sessionDirs, + creations, + released, + upliftRequests, + memoryReads, + memoryWrites, worktreeCalls, dispatched, prompts, @@ -365,6 +418,7 @@ function harness(options: HarnessOptions = {}) { replies, waits, notices, + patches, saved, activity, sessionOf, @@ -379,6 +433,10 @@ function harness(options: HarnessOptions = {}) { emit(event: BusEvent) { deliver(event) }, + /** The host reporting a card's session process gone. */ + lose(nodeID: string, reason = "the process exited") { + deliver({ type: "flow.session.lost", data: { sessionID: sessionOf.get(nodeID), reason } }) + }, /** Replays what a provider step looks like on the bus: started, then ended. */ spend( nodeID: string, @@ -483,22 +541,6 @@ describe("validation", () => { expect(log.status).toBe("done") }) - - test("names the server restart when a node points at an agent the server has not loaded", async () => { - // The server reads opencode.json once. Running anyway gives the session an - // empty permission ruleset, which fails every tool and reads like a broken - // model — so this has to be caught before dispatch. - const graph = pipeline("a") - graph.nodes[0].agent.name = "test-a" - const h = harness({ agents: ["build"] }) - - const log = await h.run(graph).done - - expect(h.dispatched).toEqual([]) - expect(log.status).toBe("error") - expect(log.nodes[0].error).toContain("restart") - expect(h.notices.some((notice) => notice.text.includes("restart"))).toBe(true) - }) }) describe("layering", () => { @@ -1243,121 +1285,6 @@ describe("usage", () => { }) }) -describe("stale engine", () => { - test("names the exact command for this host, and flags the run", async () => { - const graph = pipeline("a") - graph.nodes[0].agent.name = "untitled-mcp-server-adder" - const h = harness({ agents: ["some-other-agent"] }) - let stale = 0 - h.hooks.onEngineStale = () => { - stale += 1 - } - h.deps.serveStatus = async () => ({ - managed: false, - running: true, - url: "http://127.0.0.1:4096", - command: "bun run --cwd packages/opencode --conditions=browser src/index.ts serve --port 4096", - reason: "it was started outside OpenFlow", - }) - - const log = await h.run(graph).done - - expect(log.status).toBe("error") - expect(stale).toBe(1) - // The command, and where to run it — not a bare binary name that may not - // be on PATH or may be a different version than this checkout. - expect(log.nodes[0].error).toContain("bun run --cwd packages/opencode") - expect(log.nodes[0].error).toContain("Ctrl+C") - }) - - test("points at the button when this host owns the engine", async () => { - const graph = pipeline("a") - graph.nodes[0].agent.name = "missing-agent" - const h = harness({ agents: [] }) - h.deps.serveStatus = async () => ({ - managed: true, - running: true, - url: "http://127.0.0.1:4096", - command: "bun run --cwd packages/opencode --conditions=browser src/index.ts serve --port 4096", - }) - - const log = await h.run(graph).done - - expect(log.nodes[0].error).toContain("restart button") - }) - - test("still says something useful when the host cannot report the engine", async () => { - const graph = pipeline("a") - graph.nodes[0].agent.name = "missing-agent" - const h = harness({ agents: [] }) - - const log = await h.run(graph).done - - expect(log.nodes[0].error).toContain("opencode serve") - }) - - test("a project reporting no agents at all runs anyway when the drain has them", async () => { - const graph = pipeline("a") - graph.nodes[0].agent.name = "flow-a" - // The scoped read is empty — the directory is not a project — but the drain - // resolves its config from the engine's cwd and has the agent. - const h = harness({ agents: [], unscoped: ["build", "flow-a"] }) - let stale = 0 - h.hooks.onEngineStale = () => { - stale += 1 - } - - const log = await h.run(graph).done - - expect(log.status).toBe("done") - expect(stale).toBe(0) - expect(h.dispatched).toEqual(["a"]) - }) - - test("says git init, not restart, when nothing knows the agent and the built-ins are gone too", async () => { - const graph = pipeline("a") - graph.nodes[0].agent.name = "flow-a" - const h = harness({ agents: [], unscoped: ["build"] }) - let stale = 0 - h.hooks.onEngineStale = () => { - stale += 1 - } - h.deps.serveStatus = async () => ({ - managed: true, - running: true, - url: "http://127.0.0.1:4096", - command: "bun run --cwd packages/opencode --conditions=browser src/index.ts serve --port 4096", - }) - - const log = await h.run(graph).done - - expect(log.status).toBe("error") - expect(log.nodes[0].error).toContain("git init") - // The restart dialog offers the one fix that cannot work here. - expect(stale).toBe(0) - expect(log.nodes[0].error).not.toContain("restart button") - }) - - test("an agent missing from a project that has others is still the stale-config case", async () => { - const graph = pipeline("a") - graph.nodes[0].agent.name = "flow-a" - const h = harness({ agents: ["build"], unscoped: ["build", "flow-a"] }) - let stale = 0 - h.hooks.onEngineStale = () => { - stale += 1 - } - - const log = await h.run(graph).done - - expect(log.status).toBe("error") - // The scoped list is not empty, so the unscoped one is never consulted — a - // merged agent the running server has not reloaded is exactly this. - expect(stale).toBe(1) - expect(log.nodes[0].error).toContain("opencode serve") - expect(log.nodes[0].error).not.toContain("git init") - }) -}) - describe("resume", () => { test("a seeded node is satisfied without a session", async () => { const h = harness() @@ -1426,6 +1353,9 @@ describe("resume", () => { expect(log.nodes[0].sessionID).toBe("s-from-before") expect(h.created()).toBe(0) + // Re-attached through the host rather than prompted into blind, so a host + // that restarted since can bring the session back from its own files. + expect(h.creations.map((creation) => creation.reuse)).toEqual(["s-from-before"]) expect(h.prompts.get("a")).toContain("The run was interrupted, and is being continued") expect(log.status).toBe("done") }) @@ -2279,10 +2209,70 @@ describe("a tool call belongs to the turn that made it", () => { }) }) -describe("the tool channel is parked", () => { - test("by default a run never asks for tool calls at all", async () => { - // No MCP tool can reach a v2 session in this fork, so scanning for one - // would be a request per orchestrator turn that cannot find anything. +describe("the tool channel is the primary one", () => { + const tool = (name: string, input: unknown) => [{ name, input }] + + test("by default a host tool call decides, with no option set", async () => { + // The host answers `openflow_dispatch` / `openflow_finish` as RPC host tools; + // the engine reads them back through `sessionCalls`. + const h = harness({ + behavior: { + root: { + // Nothing fenced in the text at all: only the call carries the decision. + outputs: ["I will use the tools."], + callTurns: [ + tool("openflow_dispatch", { assignments: [{ card: "a", task: "do it" }] }), + tool("openflow_finish", { answer: "the tool decided" }), + ], + }, + a: { output: "a done" }, + }, + }) + const log = await h.run({ ...pipeline("root->a"), mode: "orchestration", dispatches: 1 }, "do the thing").done + + expect(h.prompts.get("a")).toContain("do it") + expect(log.nodes.find((node) => node.id === "root")!.output).toBe("the tool decided") + expect(log.status).toBe("done") + }) + + test("the call wins over a block in the same message", async () => { + const h = harness({ + behavior: { + root: { + outputs: [ + "I am dispatching.", + "```openflow\n" + JSON.stringify({ final: "the block decided" }) + "\n```", + ], + callTurns: [ + tool("openflow_dispatch", { assignments: [{ card: "a", task: "do it" }] }), + tool("openflow_finish", { answer: "the tool decided" }), + ], + }, + a: { output: "a done" }, + }, + }) + const log = await h.run({ ...pipeline("root->a"), mode: "orchestration", dispatches: 1 }, "do the thing").done + + expect(log.nodes.find((node) => node.id === "root")!.output).toBe("the tool decided") + }) + + test("the fenced block is the fallback when no call was made", async () => { + const h = harness({ + behavior: { + root: { + outputs: [ + "```openflow\n" + JSON.stringify({ dispatch: [{ card: "a", task: "do it" }] }) + "\n```", + "```openflow\n" + JSON.stringify({ final: "the block decided" }) + "\n```", + ], + }, + a: { output: "a done" }, + }, + }) + const log = await h.run({ ...pipeline("root->a"), mode: "orchestration", dispatches: 1 }, "do the thing").done + expect(log.nodes.find((node) => node.id === "root")!.output).toBe("the block decided") + }) + + test("switched off for a run, the engine never asks for tool calls at all", async () => { let asked = 0 const h = harness({ behavior: { @@ -2296,18 +2286,29 @@ describe("the tool channel is parked", () => { a: { output: "a done" }, }, }) - const counting = { ...h.deps, api: { ...h.deps.api, sessionCalls: async (id: string) => (asked++, []) } } + const counting = { ...h.deps, api: { ...h.deps.api, sessionCalls: async () => (asked++, []) } } const log = await start( { ...pipeline("root->a"), mode: "orchestration", dispatches: 1 }, "do the thing", h.hooks, - {}, + { toolChannel: false }, counting, ).done expect(asked).toBe(0) expect(log.nodes.find((node) => node.id === "root")!.output).toBe("the block decided") }) + + test("an orchestrator's session is created with the dispatch tools, and a leaf's is not", async () => { + const h = harness() + await h.run({ ...pipeline("root->a"), mode: "orchestration", dispatches: 1 }, "do the thing", { + toolChannel: false, + }).done + const root = h.creations.find((creation) => creation.nodeId === "root") + const leaf = h.creations.find((creation) => creation.nodeId === "a") + expect(root?.card.dispatch).toBe(true) + expect(leaf?.card.dispatch).toBeUndefined() + }) }) describe("gauntlet mode", () => { @@ -3620,3 +3621,895 @@ describe("scratch output", () => { expect(h.prompts.get("a")).not.toContain("Scratch output") }) }) + +const fence = (body: string) => ["```openflow", body, "```"].join("\n") +const dispatches = (...cards: string[]) => + fence(JSON.stringify({ dispatch: cards.map((card) => ({ card, task: `do ${card}` })) })) +const finalAnswer = (answer: string) => fence(JSON.stringify({ final: answer })) +const orchestration = (spec: string[], dispatchBudget = 1): Pipeline => ({ + ...pipeline(...spec), + mode: "orchestration", + dispatches: dispatchBudget, +}) +const mesh = (rounds = 2): Pipeline => { + const graph = pipeline("a", "b", "verdict") + graph.mode = "swarm" + graph.rounds = rounds + graph.nodes[2].role = "synthesizer" + return graph +} + +describe("prompt uplift", () => { + const spec = "the spec" + const ok = (request: UpliftRequest): UpliftResult => ({ + status: "ok", + original: request.original, + uplifted: spec, + source: "llm", + graphId: "ut-1", + specPath: "/tmp/spec.xml", + nodes: 3, + elapsedMs: 7, + }) + const failed = (reason = "the claude CLI is logged out"): UpliftResult => ({ + status: "failed", + original: "", + reason, + elapsedMs: 3, + }) + + test("an uplifted task is sent in place of the original, with every other section after it", async () => { + const h = harness({ uplift: ok }) + const log = await h.run(pipeline("a->b"), "build the thing").done + + // Uplift is given the card's own task and nothing another card produced. + expect(h.upliftRequests.map((request) => request.nodeId)).toEqual(["a", "b"]) + expect(h.upliftRequests[1]).toMatchObject({ runId: log.id, role: "b", original: "# Task\n\nbuild the thing" }) + expect(h.upliftRequests[1].original).not.toContain("a output") + + const sent = h.prompts.get("b")! + expect(sent.startsWith(`${spec}\n\n# Context from earlier cards`)).toBe(true) + expect(sent).toContain("## a (a)\n\na output") + expect(sent).toContain("You are one card in an OpenFlow pipeline") + // The spec replaced the task text; it is not sent twice. + expect(sent).not.toContain("# Task\n\nbuild the thing") + + expect(log.nodes[1].prompt).toBe(sent) + expect(log.nodes[1].uplift).toMatchObject({ + status: "ok", + original: "# Task\n\nbuild the thing", + uplifted: spec, + source: "llm", + graphId: "ut-1", + specPath: "/tmp/spec.xml", + nodes: 3, + elapsedMs: 7, + }) + }) + + test("the card's role instructions are part of the task uplift sees", async () => { + const graph = pipeline("a") + graph.nodes[0].agent.prompt = "You are the planner." + const h = harness({ uplift: ok }) + await h.run(graph, "plan it").done + + expect(h.upliftRequests[0].original).toBe("You are the planner.\n\n# Task\n\nplan it") + expect(h.prompts.get("a")).not.toContain("You are the planner.") + }) + + test("a card with no uplift dependency records none and sends the task as always", async () => { + const h = harness() + const log = await h.run(pipeline("a")).done + + expect(log.nodes[0].uplift).toBeUndefined() + expect(h.prompts.get("a")).toContain("# Task\n\ndo the thing") + expect(h.prompts.get("a")).not.toContain("Context from earlier cards") + }) + + test("a skipped uplift sends the original and says why, once", async () => { + const h = harness({ + uplift: (request) => ({ status: "skipped", original: request.original, reason: "jev-skip", elapsedMs: 1 }), + }) + const log = await h.run(pipeline("a")).done + + expect(h.prompts.get("a")).toContain("# Task\n\ndo the thing") + expect(h.prompts.get("a")).not.toContain("Context from earlier cards") + expect(log.nodes[0].uplift).toMatchObject({ status: "skipped", reason: "jev-skip" }) + const notes = h.activity.filter((row) => row.node === "a" && row.event.title.includes("uplift skipped")) + expect(new Set(notes.map((row) => row.event.id)).size).toBe(1) + expect(log.status).toBe("done") + }) + + test("a failed uplift waits for the user, and 'original' runs the original", async () => { + const asked: UpliftFailureRequest[] = [] + const h = harness({ + uplift: () => failed(), + onUpliftFailure: async (request) => { + asked.push(request) + // Nothing has been sent while the user decides. + expect(h.dispatched).toEqual([]) + return "original" + }, + }) + const log = await h.run(pipeline("a")).done + + expect(asked).toEqual([{ nodeID: "a", role: "a", reason: "the claude CLI is logged out", elapsedMs: 3 }]) + expect(h.prompts.get("a")).toContain("# Task\n\ndo the thing") + expect(log.nodes[0].uplift).toMatchObject({ + status: "declined", + declined: true, + original: "# Task\n\ndo the thing", + reason: "the claude CLI is logged out", + }) + expect(log.status).toBe("done") + }) + + test("'retry' asks uplift again and sends what the second attempt returns", async () => { + let calls = 0 + const asked: string[] = [] + const h = harness({ + uplift: (request) => (++calls === 1 ? failed("timed out after 120s") : ok(request)), + onUpliftFailure: async (request) => { + asked.push(request.reason) + return "retry" + }, + }) + const log = await h.run(pipeline("a")).done + + expect(h.upliftRequests).toHaveLength(2) + expect(asked).toEqual(["timed out after 120s"]) + expect(h.prompts.get("a")!.startsWith(spec)).toBe(true) + expect(log.nodes[0].uplift).toMatchObject({ status: "ok", uplifted: spec }) + expect(log.nodes[0].uplift?.declined).toBeUndefined() + }) + + test("three failed attempts in all end the card, however often the user says retry", async () => { + let asks = 0 + const h = harness({ + uplift: () => failed("planner crashed"), + onUpliftFailure: async () => (asks++, "retry"), + }) + const log = await h.run(pipeline("a")).done + + expect(h.upliftRequests).toHaveLength(3) + expect(asks).toBe(3) + expect(log.nodes[0]).toMatchObject({ status: "error", error: "prompt uplift failed: planner crashed" }) + expect(h.dispatched).toEqual([]) + }) + + test("'cancel' fails the card, sends nothing and opens no session; the cards behind it are skipped", async () => { + const h = harness({ uplift: () => failed("timed out after 120s"), onUpliftFailure: async () => "cancel" }) + const log = await h.run(pipeline("a->b")).done + + expect(log.nodes[0]).toMatchObject({ status: "error", error: "prompt uplift failed: timed out after 120s" }) + expect(log.nodes[1].status).toBe("skipped") + expect(h.dispatched).toEqual([]) + expect(h.creations).toEqual([]) + expect(log.status).toBe("error") + }) + + test("with no hook to ask, a failure cancels: the original is never run silently", async () => { + const h = harness({ uplift: () => failed("the claude CLI is missing") }) + const log = await h.run(pipeline("a")).done + + expect(log.nodes[0]).toMatchObject({ status: "error", error: "prompt uplift failed: the claude CLI is missing" }) + expect(log.nodes[0].uplift).toMatchObject({ status: "failed" }) + expect(h.dispatched).toEqual([]) + }) + + test("a hook that throws counts as cancel", async () => { + const h = harness({ + uplift: () => failed(), + onUpliftFailure: async () => { + throw new Error("dialog closed") + }, + }) + const log = await h.run(pipeline("a")).done + expect(log.nodes[0].status).toBe("error") + expect(h.dispatched).toEqual([]) + }) + + test("a planner that throws is a failure with its own message", async () => { + const asked: string[] = [] + const h = harness({ + uplift: async () => { + throw new Error("planner crashed") + }, + onUpliftFailure: async (request) => (asked.push(request.reason), "original"), + }) + const log = await h.run(pipeline("a")).done + expect(asked).toEqual(["planner crashed"]) + expect(log.status).toBe("done") + }) + + test("a fallback spec is a failure, whatever status carries it", async () => { + const asked: string[] = [] + const h = harness({ + uplift: (request) => ({ + status: "ok", + original: request.original, + uplifted: spec, + source: "fallback", + reason: "the claude CLI could not be reached", + elapsedMs: 2, + }), + onUpliftFailure: async (request) => (asked.push(request.reason), "cancel"), + }) + const log = await h.run(pipeline("a")).done + + expect(asked).toEqual(["the claude CLI could not be reached"]) + expect(h.dispatched).toEqual([]) + expect(log.nodes[0].error).toBe("prompt uplift failed: the claude CLI could not be reached") + }) + + test("an ok result with no spec in it is a failure too", async () => { + const asked: string[] = [] + const h = harness({ + uplift: (request) => ({ status: "ok", original: request.original, elapsedMs: 2 }), + onUpliftFailure: async (request) => (asked.push(request.reason), "cancel"), + }) + await h.run(pipeline("a")).done + expect(asked).toEqual(["the planner returned no spec"]) + }) + + test("stopping the run settles a pending failure decision as cancel", async () => { + const h = harness({ uplift: () => failed(), onUpliftFailure: () => new Promise(() => {}) }) + const run = h.run(pipeline("a")) + await flush() + await run.stop() + const log = await run.done + + expect(log.nodes[0].status).toBe("stopped") + expect(log.status).toBe("stopped") + expect(h.dispatched).toEqual([]) + expect(h.creations).toEqual([]) + }) + + test("stopping the run does not wait for a planner that is still working", async () => { + const h = harness({ uplift: () => new Promise(() => {}) }) + const run = h.run(pipeline("a")) + await flush() + await run.stop() + const log = await run.done + + expect(log.nodes[0].status).toBe("stopped") + expect(h.dispatched).toEqual([]) + }) + + test("protocol turns are never uplifted: a re-dispatch, the dispatch result and the forced answer", async () => { + const h = harness({ + uplift: ok, + behavior: { root: { outputs: [dispatches("a"), dispatches("a"), finalAnswer("done")] }, a: {} }, + }) + const log = await h.run(orchestration(["root->a"], 2)).done + + expect(log.status).toBe("done") + // The orchestrator's own first turn and the subagent's first assignment, nothing else. + expect(h.upliftRequests.map((request) => request.nodeId)).toEqual(["root", "a"]) + expect(h.upliftRequests[1].original).toContain("# Your assignment\n\ndo a") + const again = h.promptLog.filter((entry) => entry.node === "a")[1].text + expect(again).toContain("A new assignment") + expect(again).not.toContain(spec) + const rootTurns = h.promptLog.filter((entry) => entry.node === "root") + expect(rootTurns.length).toBeGreaterThan(2) + expect(rootTurns[0].text.startsWith(spec)).toBe(true) + for (const turn of rootTurns.slice(1)) expect(turn.text).not.toContain(spec) + expect(rootTurns[1].text).toContain("# What your cards returned") + }) + + test("a swarm uplifts each peer's round 1 and the synthesis, never a later round", async () => { + const h = harness({ uplift: ok }) + await h.run(mesh(2)).done + + expect(h.upliftRequests.map((request) => request.nodeId).sort()).toEqual(["a", "b", "verdict"]) + const roundTwo = h.promptLog.filter((entry) => entry.text.includes("# Round 2 of 2")) + expect(roundTwo).toHaveLength(2) + for (const turn of roundTwo) expect(turn.text).not.toContain(spec) + // The debate the synthesizer weighs is context, not task: it rides after the spec. + const synthesis = h.prompts.get("verdict")! + expect(synthesis.startsWith(spec)).toBe(true) + expect(synthesis).toContain("# Final positions") + expect(h.upliftRequests.find((request) => request.nodeId === "verdict")!.original).not.toContain("Final positions") + }) + + test("a verification pass and a gauntlet critic's briefing are never uplifted", async () => { + const graph = pipeline("builder->reviewer") + graph.verify = {} + const h = harness({ uplift: ok, behavior: { reviewer: { output: "looks right\nVERDICT: PASS" } } }) + await h.run(graph).done + + // Once each for the two cards' own turns; the verifier's new session is a protocol turn. + expect(h.upliftRequests.map((request) => request.nodeId)).toEqual(["builder", "reviewer"]) + const verifying = h.promptLog.filter((entry) => entry.node === "reviewer") + expect(verifying).toHaveLength(2) + expect(verifying[1].text).toContain("You are the verifier of an OpenFlow run") + expect(verifying[1].text).not.toContain(spec) + }) + + test("a card continued from an interrupted run is not uplifted again", async () => { + const h = harness({ uplift: ok }) + await h.run(pipeline("a"), "do the thing", { sessions: { a: "s-old" } }).done + + expect(h.upliftRequests).toEqual([]) + expect(h.prompts.get("a")).toContain("The run was interrupted") + expect(h.prompts.get("a")).toContain("# Task\n\ndo the thing") + }) + + test("a retried send re-sends the same text without asking uplift again", async () => { + const h = harness({ + uplift: ok, + behavior: { a: { errors: ["Provider request failed with HTTP 429 Too Many Requests"] } }, + }) + const log = await h.run(pipeline("a"), "do the thing", { rateLimitBackoff: 1 }).done + + expect(log.status).toBe("done") + expect(h.upliftRequests).toHaveLength(1) + const sends = h.promptLog.filter((entry) => entry.node === "a") + expect(sends).toHaveLength(2) + expect(sends[1].text).toBe(sends[0].text) + }) + + test("a card with uplift off is sent as written and records why", async () => { + const graph = pipeline("a") + graph.nodes[0].agent.uplift = false + const h = harness({ uplift: ok }) + const log = await h.run(graph).done + + expect(h.upliftRequests).toEqual([]) + expect(h.prompts.get("a")).toContain("# Task\n\ndo the thing") + expect(log.nodes[0].uplift).toMatchObject({ status: "skipped", reason: "uplift is off for this card" }) + }) + + test("a leading raw: opts out and is stripped from what is sent", async () => { + const h = harness({ uplift: ok }) + const log = await h.run(pipeline("a"), "raw: just do it").done + + expect(h.upliftRequests).toEqual([]) + expect(h.prompts.get("a")).toContain("# Task\n\njust do it") + expect(h.prompts.get("a")).not.toContain("raw:") + expect(log.nodes[0].uplift).toMatchObject({ status: "skipped", reason: "raw: prefix" }) + }) + + test("raw: is stripped even where there is no uplift to opt out of", async () => { + const h = harness() + await h.run(pipeline("a"), "raw: just do it").done + expect(h.prompts.get("a")).toContain("# Task\n\njust do it") + }) + + test("a task that already is a spec is not uplifted again", async () => { + const already = "\nfix it\n" + const h = harness({ uplift: ok }) + const log = await h.run(pipeline("a"), already).done + + expect(h.upliftRequests).toEqual([]) + expect(h.prompts.get("a")).toContain(already) + expect(log.nodes[0].uplift).toMatchObject({ status: "skipped", reason: "already a spec" }) + }) + + test("a card in a worktree has the planner read that tree", async () => { + const h = harness({ + uplift: ok, + worktrees: "available", + behavior: { root: { outputs: [dispatches("a", "b"), finalAnswer("done")] }, a: {}, b: {} }, + }) + const graph = { ...orchestration(["root->a", "root->b"]), isolate: true } + await h.run(graph).done + + expect(h.upliftRequests.find((request) => request.nodeId === "root")!.cwd).toBeUndefined() + expect(h.upliftRequests.find((request) => request.nodeId === "a")!.cwd).toBe(h.sessionDirs.get("s2")) + }) +}) + +describe("project memory", () => { + const block = "untrusted evidence: the flag lives in cli/flags.ts" + const found = (): MemoryReadResult => ({ + status: "ok", + block, + items: [ + { + id: "1", + scope: "repo:o/n", + kind: "fact", + subject: "openflow/test/run-0/x", + preview: "the flag lives in cli/flags.ts", + origin: "brief", + run: "run-0", + card: "x", + }, + ], + scopes: ["repo:o/n"], + briefState: "complete", + elapsedMs: 4, + }) + const spec = "the spec" + const ok = (request: UpliftRequest): UpliftResult => ({ + status: "ok", + original: request.original, + uplifted: spec, + elapsedMs: 1, + }) + + test("recalled memory is appended after the context, and the read is keyed on the task", async () => { + const h = harness({ memory: { read: found } }) + const log = await h.run(pipeline("a->b"), "build the thing").done + + expect(h.memoryReads[1]).toEqual({ + runId: log.id, + nodeId: "b", + pipeline: "test", + role: "b", + query: "# Task\n\nbuild the thing", + }) + const sent = h.prompts.get("b")! + expect(sent.endsWith(block)).toBe(true) + expect(sent.indexOf("# Upstream output")).toBeLessThan(sent.indexOf(block)) + expect(log.nodes[1].memory?.read).toEqual({ + status: "ok", + items: [ + { + scope: "repo:o/n", + subject: "openflow/test/run-0/x", + preview: "the flag lives in cli/flags.ts", + origin: "brief", + run: "run-0", + card: "x", + }, + ], + briefState: "complete", + }) + }) + + test("with an uplifted task the block still comes last, after the context section", async () => { + const h = harness({ uplift: ok, memory: { read: found } }) + await h.run(pipeline("a->b")).done + + const sent = h.prompts.get("b")! + expect(sent.startsWith(spec)).toBe(true) + expect(sent.indexOf("# Context from earlier cards")).toBeLessThan(sent.indexOf(block)) + expect(sent.endsWith(block)).toBe(true) + // Memory is queried with the original task, not the spec. + expect(h.memoryReads[1].query).toBe("# Task\n\ndo the thing") + }) + + test("an empty recall appends nothing and says so", async () => { + const h = harness({ memory: {} }) + const log = await h.run(pipeline("a")).done + + expect(h.prompts.get("a")).not.toContain("project-memory") + expect(log.nodes[0].memory?.read).toMatchObject({ status: "empty", items: [] }) + }) + + test("unavailable memory fails open: the card runs without it and the card says so", async () => { + const h = harness({ + memory: { + read: () => ({ + status: "unavailable", + block: "", + items: [], + scopes: [], + error: "Substrate is not reachable at 127.0.0.1:9000", + elapsedMs: 9, + }), + }, + }) + const log = await h.run(pipeline("a")).done + + expect(log.status).toBe("done") + expect(h.prompts.get("a")).toContain("# Task\n\ndo the thing") + expect(log.nodes[0].memory?.read).toMatchObject({ + status: "unavailable", + error: "Substrate is not reachable at 127.0.0.1:9000", + }) + expect(h.activity.some((row) => row.node === "a" && row.event.title.includes("memory unavailable"))).toBe(true) + }) + + test("a block returned with a status other than ok is not sent", async () => { + const h = harness({ memory: { read: () => ({ ...found(), status: "disabled" }) } }) + const log = await h.run(pipeline("a")).done + + expect(h.prompts.get("a")).not.toContain(block) + expect(log.nodes[0].memory?.read?.status).toBe("disabled") + }) + + test("a read that throws is unavailable, not a failed card", async () => { + const h = harness({ + memory: { + read: async () => { + throw new Error("socket hang up") + }, + }, + }) + const log = await h.run(pipeline("a")).done + + expect(log.status).toBe("done") + expect(log.nodes[0].memory?.read).toMatchObject({ status: "unavailable", error: "socket hang up" }) + }) + + test("only task turns read memory: a re-dispatch, a retry and a continuation do not", async () => { + const h = harness({ + memory: { read: found }, + behavior: { root: { outputs: [dispatches("a"), dispatches("a"), finalAnswer("done")] }, a: {} }, + }) + await h.run(orchestration(["root->a"], 2), "do the thing", { sessions: {} }).done + expect(h.memoryReads.map((read) => read.nodeId)).toEqual(["root", "a"]) + + const retried = harness({ + memory: { read: found }, + behavior: { a: { errors: ["Provider request failed with HTTP 429 Too Many Requests"] } }, + }) + await retried.run(pipeline("a"), "do the thing", { rateLimitBackoff: 1 }).done + expect(retried.memoryReads).toHaveLength(1) + + const resumed = harness({ memory: { read: found } }) + await resumed.run(pipeline("a"), "do the thing", { sessions: { a: "s-old" } }).done + expect(resumed.memoryReads).toEqual([]) + }) + + test("a card opted out of uplift is opted out of memory too", async () => { + const graph = pipeline("a") + graph.nodes[0].agent.uplift = false + const h = harness({ memory: { read: found } }) + await h.run(graph).done + expect(h.memoryReads).toEqual([]) + + const raw = harness({ memory: { read: found } }) + await raw.run(pipeline("a"), "raw: as written").done + expect(raw.memoryReads).toEqual([]) + expect(raw.prompts.get("a")).not.toContain(block) + }) + + test("a finished card is written to memory with its answer", async () => { + const h = harness({ memory: {} }) + const log = await h.run(pipeline("a->b")).done + + expect(h.memoryWrites).toEqual([ + { runId: log.id, nodeId: "a", pipeline: "test", role: "a", outcome: "done", output: "a output" }, + { runId: log.id, nodeId: "b", pipeline: "test", role: "b", outcome: "done", output: "b output" }, + ]) + expect(log.nodes[0].memory?.write).toEqual({ status: "stored", subject: "openflow/a" }) + }) + + test("a card that failed is written with its error, and the cards behind it are not written", async () => { + const h = harness({ memory: {}, behavior: { a: { error: "provider exploded" } } }) + const log = await h.run(pipeline("a->b")).done + + expect(h.memoryWrites).toEqual([ + { runId: log.id, nodeId: "a", pipeline: "test", role: "a", outcome: "error", output: "provider exploded" }, + ]) + expect(log.nodes[1].status).toBe("skipped") + expect(log.nodes[1].memory).toBeUndefined() + }) + + test("a card that was stopped is written as stopped", async () => { + const h = harness({ memory: {}, behavior: { a: { hold: true } } }) + const run = h.run(pipeline("a")) + await flush() + await run.stop() + const log = await run.done + + expect(h.memoryWrites.map((write) => [write.nodeId, write.outcome])).toEqual([["a", "stopped"]]) + expect(log.nodes[0].memory?.write?.status).toBe("stored") + }) + + test("a reused card is not written again", async () => { + const h = harness({ memory: {} }) + await h.run(pipeline("a->b"), "do the thing", { resume: { a: "from last time" } }).done + expect(h.memoryWrites.map((write) => write.nodeId)).toEqual(["b"]) + }) + + test("a write that was refused or failed is shown on the card, and the run still succeeds", async () => { + const denied = harness({ + memory: { write: () => ({ status: "denied", error: "the token cannot write repo:o/n" }) }, + }) + const log = await denied.run(pipeline("a")).done + expect(log.status).toBe("done") + expect(log.nodes[0].memory?.write).toMatchObject({ status: "denied", error: "the token cannot write repo:o/n" }) + + const broken = harness({ + memory: { + write: async () => { + throw new Error("connection refused") + }, + }, + }) + const second = await broken.run(pipeline("a")).done + expect(second.status).toBe("done") + expect(second.nodes[0].memory?.write).toMatchObject({ status: "unavailable", error: "connection refused" }) + }) + + test("a write that never answers does not hold the card or the run", async () => { + const h = harness({ memory: { write: () => new Promise(() => {}) } }) + const log = await h.run(pipeline("a->b"), "do the thing", { backgroundGrace: 20 }).done + + // `b` ran, so the write for `a` did not stand in its way. + expect(log.status).toBe("done") + expect(log.nodes.map((node) => node.status)).toEqual(["done", "done"]) + expect(log.nodes[0].memory?.write?.status).toBe("running") + }) + + test("a swarm peer is written once, after its last round, and so is the synthesizer", async () => { + const h = harness({ memory: {} }) + await h.run(mesh(2)).done + expect(h.memoryWrites.map((write) => write.nodeId).sort()).toEqual(["a", "b", "verdict"]) + }) + + test("an orchestration writes the root once, and a dispatched card after its work", async () => { + const h = harness({ + memory: {}, + behavior: { root: { outputs: [dispatches("a"), finalAnswer("the answer")] }, a: { output: "a did it" } }, + }) + await h.run(orchestration(["root->a"])).done + + const byCard = Object.fromEntries(h.memoryWrites.map((write) => [write.nodeId, write.output])) + expect(byCard).toEqual({ a: "a did it", root: "the answer" }) + expect(h.memoryWrites).toHaveLength(2) + }) + + test("a host with no memory records none and writes none", async () => { + const h = harness() + const log = await h.run(pipeline("a")).done + expect(log.nodes[0].memory).toBeUndefined() + expect(h.memoryWrites).toEqual([]) + }) +}) + +describe("events from the host", () => { + test("a swarm status lands on the card that owns the session", async () => { + const h = harness({ behavior: { a: { hold: true } } }) + const run = h.run(pipeline("a")) + await flush() + const status = { + mode: "auto" as const, + active: true, + counts: { queued: 0, running: 1, completed: 0, failed: 0, cancelled: 0 }, + agents: [{ agent: "a05-backend", state: "running" as const, activity: "bash", at: 1 }], + updatedAt: 1, + } + h.emit({ type: "flow.swarm.status", data: { sessionID: h.sessionOf.get("a"), status } }) + h.emit({ type: "flow.swarm.status", data: { sessionID: "someone-elses", status: { ...status, active: false } } }) + h.release("a") + const log = await run.done + + expect(log.nodes[0].swarm).toEqual(status) + expect(h.patches.filter((entry) => entry.patch.swarm).map((entry) => entry.id)).toEqual(["a"]) + }) + + test("a notice reaches the user, named for the card when a session owns it", async () => { + const h = harness({ behavior: { a: { hold: true } } }) + const run = h.run(pipeline("a")) + await flush() + const sessionID = h.sessionOf.get("a") + h.emit({ type: "flow.notice", data: { sessionID, level: "info", message: "swarm extension loaded" } }) + h.emit({ type: "flow.notice", data: { sessionID, level: "error", message: "extension failed" } }) + h.emit({ type: "flow.notice", data: { level: "error", message: "host is low on memory" } }) + h.emit({ type: "flow.notice", data: { sessionID: "another-run", level: "error", message: "not ours" } }) + h.emit({ type: "flow.notice", data: { sessionID, level: "info", message: "" } }) + h.release("a") + await run.done + + expect(h.notices).toEqual([ + { kind: "info", text: "a: swarm extension loaded" }, + { kind: "error", text: "a: extension failed" }, + { kind: "error", text: "host is low on memory" }, + ]) + }) + + test("a lost session process ends the wait and the card is recreated on the transport budget", async () => { + const h = harness({ behavior: { a: { hold: true } } }) + const run = h.run(pipeline("a"), "do the thing", { rateLimitBackoff: 1 }) + await flush() + expect(h.created()).toBe(1) + + h.lose("a", "the omp process exited with code 1") + await flush() + // A second session was opened and the same task sent into it. + expect(h.created()).toBe(2) + h.release("a") + const log = await run.done + + expect(log.status).toBe("done") + expect(log.nodes[0].sessionID).toBe("s2") + expect(h.promptLog.filter((entry) => entry.node === "a")).toHaveLength(2) + const row = h.activity.find((entry) => entry.node === "a" && entry.event.title.includes("session was lost")) + expect(row?.event.body).toContain("the omp process exited with code 1") + }) + + test("a lost session with the budget spent fails the card", async () => { + const h = harness({ behavior: { a: { hold: true } } }) + const run = h.run(pipeline("a"), "do the thing", { rateLimitBackoff: 0 }) + await flush() + h.lose("a", "the omp process exited") + const log = await run.done + + expect(log.nodes[0].status).toBe("error") + expect(log.nodes[0].error).toContain("the omp process exited") + expect(h.created()).toBe(1) + }) + + test("a lost event for a session the run does not own is ignored", async () => { + const h = harness({ behavior: { a: { hold: true } } }) + const run = h.run(pipeline("a")) + await flush() + h.emit({ type: "flow.session.lost", data: { sessionID: "another-run", reason: "gone" } }) + await flush() + expect(h.created()).toBe(1) + h.release("a") + expect((await run.done).status).toBe("done") + }) +}) + +describe("releasing sessions", () => { + test("a pipeline card's session goes back as soon as the card is finished, and every other at the end", async () => { + const h = harness({ release: true, behavior: { b: { hold: true } } }) + const run = h.run(pipeline("a->b")) + await flush() + + // `a` is done and will not be prompted again; `b` is still working. + expect(h.released).toEqual(["s1"]) + h.release("b") + await run.done + expect(h.released).toEqual(["s1", "s2"]) + }) + + test("a run releases everything it created exactly once, including after a stop", async () => { + const h = harness({ release: true, behavior: { a: { hold: true } } }) + const run = h.run(pipeline("a")) + await flush() + await run.stop() + await run.done + + expect(h.released).toEqual(["s1"]) + }) + + test("a gauntlet critic's replaced session is released when it is replaced", async () => { + const graph = pipeline("root->builder", "root->reviewer") + for (const node of graph.nodes) node.agent.model = "openai/gpt-x" + const h = harness({ + release: true, + behavior: { + root: { output: dispatches("builder", "reviewer") }, + builder: { output: "built it" }, + reviewer: { output: "the bar still wins" }, + }, + models: ["openai/gpt-x"], + prices: { "openai/gpt-x": [{ input: 2, output: 10, cache: { read: 0.5, write: 4 } }] }, + }) + await h.run({ ...graph, mode: "orchestration", gauntlet: { bar: "the reference build", stall: 1 } }).done + + const critics = h.creations.map((creation, index) => [creation.nodeId, `s${index + 1}`]).filter(([id]) => id === "reviewer") + expect(critics.length).toBeGreaterThan(1) + expect(h.released).toContain(critics[0][1]) + // Every session the run opened is back with the host, none twice. + expect(new Set(h.released).size).toBe(h.released.length) + expect([...h.released].sort()).toEqual(h.creations.map((_, index) => `s${index + 1}`).sort()) + }) + + test("a lost session is released rather than left for the host", async () => { + const h = harness({ release: true, behavior: { a: { errors: ["session not found"], output: "again" } } }) + await h.run(pipeline("a"), "go", { rateLimitBackoff: 1 }).done + + expect(h.released).toEqual(["s1", "s2"]) + }) + + test("swarm peers keep one session across rounds and are released after the last", async () => { + const h = harness({ release: true }) + await h.run(mesh(2)).done + + expect(h.created()).toBe(3) + expect([...h.released].sort()).toEqual(["s1", "s2", "s3"]) + }) + + test("a host that cannot release is simply not asked", async () => { + const h = harness() + const log = await h.run(pipeline("a->b")).done + expect(log.status).toBe("done") + expect(h.released).toEqual([]) + }) +}) + +describe("what a session is created with", () => { + test("the card, the run and the node, in OpenFlow's own vocabulary", async () => { + const graph = pipeline("a") + graph.nodes[0].role = "Coder" + graph.nodes[0].agent = { + prompt: "You write code.", + model: "openai/gpt-x", + tools: { write: true, bash: false, ghost: true }, + mcp: ["docs"], + // A name from the opencode era is tolerated and never used. + name: "flow-a", + } + const h = harness({ models: ["openai/gpt-x"] }) + const log = await h.run(graph, "do the thing", { permissions: "manual" }).done + + expect(h.creations).toEqual([ + { + runId: log.id, + nodeId: "a", + card: { key: "test-coder-a", role: "Coder", prompt: "You write code.", tools: { edit: true, bash: false }, mcp: ["docs"] }, + model: "openai/gpt-x", + directory: undefined, + permissions: "manual", + swarm: undefined, + }, + ]) + expect(log.nodes[0].agent).toBeUndefined() + }) + + test("swarm: the card's own mode, then the canvas default, then the host's", async () => { + const graph = { ...pipeline("a", "b", "c"), defaultSwarm: "auto" as const } + graph.nodes[1].agent.swarm = "always" + graph.nodes[2].agent.swarm = "off" + const h = harness() + await h.run(graph).done + + const swarmOf = Object.fromEntries(h.creations.map((creation) => [creation.nodeId, creation.swarm])) + expect(swarmOf).toEqual({ a: "auto", b: "always", c: "off" }) + + const plain = harness() + await plain.run(pipeline("a")).done + expect(plain.creations[0].swarm).toBeUndefined() + }) + + test("an isolated card is created in its own tree", async () => { + const h = harness({ + worktrees: "available", + behavior: { root: { outputs: [dispatches("a", "b"), finalAnswer("done")] } }, + }) + const log = await h.run({ ...orchestration(["root->a", "root->b"]), isolate: true }).done + + expect(h.creations.find((creation) => creation.nodeId === "a")?.directory).toBe(`/tmp/${log.id}/a`) + expect(h.creations.find((creation) => creation.nodeId === "root")?.directory).toBeUndefined() + }) +}) + +describe("the run log keeps what the stages did", () => { + test("original and uplifted prompts, memory and swarm state all survive into the saved log", async () => { + const spec = "the spec" + const h = harness({ + uplift: (request) => ({ status: "ok", original: request.original, uplifted: spec, elapsedMs: 5 }), + memory: { + read: () => ({ + status: "ok", + block: "x", + items: [{ id: "1", scope: "repo:o/n", kind: "fact", subject: "s", preview: "p", origin: "search" }], + scopes: ["repo:o/n"], + elapsedMs: 1, + }), + }, + behavior: { a: { hold: true } }, + }) + const run = h.run(pipeline("a"), "build it") + await flush() + const status = { + mode: "always" as const, + active: true, + counts: { queued: 0, running: 0, completed: 1, failed: 0, cancelled: 0 }, + agents: [{ agent: "a05-backend", state: "completed" as const, at: 2 }], + updatedAt: 2, + } + h.emit({ type: "flow.swarm.status", data: { sessionID: h.sessionOf.get("a"), status } }) + h.release("a") + await run.done + + const saved = JSON.parse(JSON.stringify(h.saved.at(-1))) as RunLog + const node = saved.nodes[0] + expect(node.uplift).toMatchObject({ status: "ok", original: "# Task\n\nbuild it", uplifted: spec }) + expect(node.prompt).toContain(spec) + expect(node.prompt).toContain("x") + expect(node.memory?.read).toMatchObject({ status: "ok", items: [{ scope: "repo:o/n", subject: "s", origin: "search" }] }) + expect(node.memory?.write).toMatchObject({ status: "stored" }) + expect(node.swarm).toEqual(status) + }) + + test("a failed uplift is in the saved log with the reason", async () => { + const h = harness({ + uplift: () => ({ status: "failed", original: "", reason: "timed out after 120s", elapsedMs: 3 }), + }) + await h.run(pipeline("a")).done + + const node = h.saved.at(-1)!.nodes[0] + expect(node.uplift).toMatchObject({ status: "failed", reason: "timed out after 120s", original: "# Task\n\ndo the thing" }) + expect(node.error).toBe("prompt uplift failed: timed out after 120s") + }) +}) diff --git a/packages/flow/src/server/engine.ts b/packages/flow/src/server/engine.ts index 3b0270daf7..df1f84ab90 100644 --- a/packages/flow/src/server/engine.ts +++ b/packages/flow/src/server/engine.ts @@ -2,10 +2,15 @@ import { collisionNote, collisionsIn, exceptUnder, writesOf, type Write } from " import { addDiff, attribute, deltaOf } from "../graph/diff" import { bestRound, ledgerNote, stalledRounds, verdictSummary, type LedgerRound } from "../graph/ledger" import { isolates, mergeNote } from "../graph/worktree" +import type { EngineApi, ModelInfo, PermissionReply, QuestionInfo } from "../../shared/engine" +import type { MemoryInfo, MemoryReadRequest, MemoryReadResult, MemoryWriteRequest, MemoryWriteResult } from "../../shared/memory" +import type { SwarmStatus } from "../../shared/swarm" +import type { UpliftInfo, UpliftRequest, UpliftResult } from "../../shared/uplift" import { fromToolCall, MCP_REACHES_SESSIONS, parseDispatch } from "../graph/dispatch" import { isCritic, orchestrationShape } from "../graph/orchestration" import { - buildPrompt, + alreadyUplifted, + buildSections, criticPrompt, dispatchFirstPrompt, dispatchResultPrompt, @@ -18,15 +23,21 @@ import { unverifiedNote, verdictPrompt, verifyPrompt, - orchestratorPrompt, + orchestratorSections, + plainPrompt, protocolPrompt, reassignPrompt, - subOrchestratorPrompt, + stripRaw, + subOrchestratorSections, silentWriterNote, - subagentPrompt, + subagentSections, swarmPrompt, + swarmSections, + taskOf, toolFailureNote, - synthesisPrompt, + synthesisSections, + upliftedPrompt, + type PromptSection, } from "../graph/prompt" import { swarmShape } from "../graph/swarm" import { verdictIn } from "../graph/verdict" @@ -55,7 +66,9 @@ import { import { ancestors, downstream, layer, upstream } from "../graph/validate" import { applyEvent, createActivity, persistable } from "./activity" import * as api from "./client" -import { store, toolMap, type ServeStatus, type WorktreeRef } from "./store" +import { cardSpec, swarmOf, toolMap } from "./card-spec" +import { createStages } from "./stages" +import { store, type WorktreeRef } from "./store" import { mergeSpend, summarize, type PricedModel } from "./usage" export type NodePatch = { @@ -75,6 +88,12 @@ export type NodePatch = { diff?: CardDiff /** Whether the card's last judging turn ran a shell. Absent on any other card. */ ranShell?: boolean + /** Prompt Uplift: the original task, what it became, and why when it did not. */ + uplift?: UpliftInfo + /** Shared memory: what the card read before it ran and what it wrote after. */ + memory?: MemoryInfo + /** Agent Swarm activity under the card's session, as the host last reported it. */ + swarm?: SwarmStatus } export type EngineHooks = { @@ -88,14 +107,20 @@ export type EngineHooks = { onRun?: (run: RunLog) => void onNotice?: (kind: "info" | "error", text: string) => void /** Only called under the `manual` policy. Resolve with the reply to send. */ - onPermission?: (request: PermissionRequest) => Promise + onPermission?: (request: PermissionRequest) => Promise /** Resolve with one array of chosen labels per question, or undefined to reject. */ onQuestion?: (request: QuestionRequest) => Promise /** - * Called when a run failed because the engine is running older config than - * what is on disk — the UI opens its restart affordance. + * Prompt Uplift failed for a card and nothing has been sent to it. Resolve with + * what to do: run the original prompt, ask uplift again (three attempts in + * all), or cancel the card. + * + * Absent means `cancel` — the original is never run silently, because a card + * the user believes was uplifted and was not is the failure this exists to + * surface. A stopped run settles a pending decision as `cancel`, the way it + * settles a pending question. */ - onEngineStale?: () => void + onUpliftFailure?: (request: UpliftFailureRequest) => Promise<"original" | "retry" | "cancel"> /** * Called once a question is settled, including when the engine gave up * waiting. Without it a timed-out prompt stays on screen forever, still @@ -179,12 +204,18 @@ export type RunOptions = { * Whether to look for the orchestrator's decision in a tool call before * falling back to the fenced block. * - * Defaults to `MCP_REACHES_SESSIONS`, which is `false`: the v2 session runner - * this fork drives has no MCP bridge, so the scan can only ever come back - * empty. Tests set it to exercise the channel, and it becomes the default the - * day upstream wires MCP into v2. + * Defaults to `MCP_REACHES_SESSIONS`, which is `true`: the host answers + * `openflow_dispatch` / `openflow_finish` as RPC host tools, and the engine + * reads the calls back through `EngineApi.sessionCalls`. Switching it off + * leaves only the fenced block, which is what a host without those tools is. */ toolChannel?: boolean + /** + * How long the end of the run waits for memory writes and session releases, + * in milliseconds. They never hold a card, but the run log is more honest if + * their outcome lands in it; past this the run ends with them still pending. + */ + backgroundGrace?: number } export const DEFAULT_MAX_PARALLEL = 4 @@ -240,6 +271,9 @@ export const DEFAULT_QUESTION_TIMEOUT = 5 * 60_000 export const DEFAULT_CHECKPOINT_EVERY = 2_000 /** How long to wait before re-reading a model catalog that looked incomplete. */ export const DEFAULT_CATALOG_RETRY = 1_500 +/** Prompt Uplift attempts per task turn, the first included, before a failure ends the card. */ +const UPLIFT_ATTEMPTS = 3 +export const DEFAULT_BACKGROUND_GRACE = 10_000 export type Run = { log: RunLog @@ -252,42 +286,20 @@ export type Run = { * client and the real store; tests pass fakes instead of standing up a server. */ export type EngineDeps = { - api: Pick< - typeof api, - | "subscribe" - | "createSession" - | "prompt" - | "waitForIdle" - | "transcript" - | "interrupt" - | "replyPermission" - | "replyQuestion" - | "rejectQuestion" - | "accepts" - | "models" - | "agents" - | "describe" - > & { - /** - * Authoritative per-step usage for a finished session. Optional so a test - * double can leave it out — the engine then keeps whatever the event bus - * reported, which is the same data one round-trip earlier. - */ - sessionSteps?: typeof api.sessionSteps - /** - * Tool calls a session made, newest first — how an orchestrator's dispatch - * is read. Optional for the same reason as `sessionSteps`: a test double - * may leave it out, and a host without the MCP server installed has no tool - * calls to read anyway. Absent means the text fallback decides. - */ - sessionCalls?: typeof api.sessionCalls - /** - * The agent list with no directory on it — what the drain sees. Optional so - * a test double can leave it out; absent means an empty scoped list is read - * as the stale-config case it was read as before, which is the answer that - * was right whenever the directory is a real project. - */ - agentsUnscoped?: typeof api.agentsUnscoped + api: EngineApi + /** + * Prompt Uplift for a card's task turn. Optional so a test double can omit it: + * absent means every task is sent as written and the card records no uplift. + */ + uplift?: (request: UpliftRequest) => Promise + /** + * Shared memory across the cards of a run (Agent Substrate, host-side). + * Optional for the same reason; `read` fails open and `write` never blocks a + * card from finishing. + */ + memory?: { + read(request: MemoryReadRequest): Promise + write(request: MemoryWriteRequest): Promise } saveRun: (log: RunLog) => Promise /** @@ -321,23 +333,27 @@ export type EngineDeps = { * before this behaved. */ scratch?: typeof store.scratch - /** - * The engine process this host proxies, used to print the exact restart - * command in the one error that can only be fixed by restarting it. - */ - serveStatus?: () => Promise } +const stages = createStages() + const live: EngineDeps = { api, + uplift: stages.uplift, + memory: stages.memory, saveRun: (log) => store.saveRun(log), - serveStatus: () => store.serverStatus(), worktrees: { open: store.openWorktrees, merge: store.mergeWorktrees, cleanup: store.cleanupWorktrees }, treeStat: () => store.treeStat(), checkpoint: (run, round) => store.checkpoint(run, round), scratch: (run) => store.scratch(run), } +/** What a turn sends: sections for the card's own task turn, a string for every protocol turn. */ +type TurnText = string | PromptSection[] + +/** What uplift and memory decided for one task turn. */ +type Staged = { uplifted?: string; memory: string } + /** * How much upstream context a node receives. * - `direct`: only the nodes wired straight into it. @@ -369,13 +385,15 @@ export type QuestionRequest = { sessionID: string nodeID: string role: string - questions: api.QuestionInfo[] + questions: QuestionInfo[] } +export type UpliftFailureRequest = { nodeID: string; role: string; reason: string; elapsedMs: number } + /** - * Executes a canvas over `opencode serve`. + * Executes a canvas over the Omp engine (`EngineApi`). * - * One node = one primary session, whatever the mode. Everything a session needs + * One node = one Omp session, whatever the mode. Everything a session needs * — creating it, prompting it, waiting it out, draining its transcript, pricing * it, streaming its activity — is `runNode` below and is shared. What a mode * changes is only the *scheduler*: which nodes run, in what order, and what text @@ -401,8 +419,9 @@ export function start( const runFiles = options.attachments ?? [] const resume = options.resume ?? {} const toolChannel = options.toolChannel ?? MCP_REACHES_SESSIONS + const backgroundGrace = Math.max(0, options.backgroundGrace ?? DEFAULT_BACKGROUND_GRACE) // Preflight only covers what this run will dispatch: a reused node creates no - // session, so a model or agent it names having gone missing cannot break it. + // session, so a model it names having gone missing cannot break it. const dispatching = pipeline.nodes.filter((node) => resume[node.id] === undefined) // Preflight says the same thing in words the user can act on, but the engine // is callable without it, and running a swarm's graph through the pipeline @@ -447,6 +466,13 @@ export function start( const order = new Map(validation.layers.flatMap((ids, index) => ids.map((id) => [id, index] as const))) const controller = new AbortController() + /** + * Settles when the run is stopped. Raced against every wait on something + * outside the engine (uplift, memory, a person's decision), so stopping the + * run never sits behind a planner that has minutes left to time out. + */ + const stopped = Promise.withResolvers() + controller.signal.addEventListener("abort", () => stopped.resolve(undefined), { once: true }) const sessions = new Map() // sessionID -> nodeID /** nodeID -> the session it opened, so a later turn can prompt into it again. */ const nodeSession = new Map() @@ -460,6 +486,14 @@ export function start( const nodeDir = new Map() /** Every tree opened this run, so the run can take them all down at the end. */ const opened = new Map() + /** sessionID -> why the host reports its process gone. Read by the turn using it. */ + const dead = new Map() + /** sessionID -> the abort of the turn waiting on it, so a dead process does not hold the turn until `nodeTimeout`. */ + const waiting = new Map() + /** Every session this run opened or adopted and has not yet handed back to the host. */ + const held = new Set() + /** Fire-and-forget work (memory writes, session releases), awaited with a cap when the run ends. */ + const background = new Set>() /** * What every orchestration round produced, across the whole run. * @@ -490,6 +524,7 @@ export function start( if (resume[id] !== undefined || !sessionID) continue nodeSession.set(id, sessionID) sessions.set(sessionID, id) + held.add(sessionID) carried.add(id) } /** Tool-call ids already read, so an old call cannot decide a new turn. */ @@ -506,7 +541,7 @@ export function start( const active = new Set() // sessionIDs still running const answered = new Set() // permission requests already replied to const asked = new Set() // question requests already handled - const catalog = new Map>[number]>() + const catalog = new Map() const nodes = new Map(pipeline.nodes.map((node) => [node.id, node] as const)) const outputs = new Map() const failed = new Set() @@ -541,7 +576,6 @@ export function start( id: node.id, role: node.role, status: "queued", - agent: node.agent.name, model: node.agent.model, })), } @@ -690,10 +724,26 @@ export function start( // run never created — it is the thing that knows they belong to a node. activity.consume(event) const sessionID = event.data?.sessionID + // A notice may belong to no session at all (the host's own, an extension + // that failed to load). One that names a session this run does not own is + // another run's, and the stream is shared. + if (event.type === "flow.notice") { + const owner = sessionID ? sessions.get(sessionID) : undefined + if (sessionID && !owner) return + const message = String(event.data?.message ?? "") + if (!message) return + hooks.onNotice?.(event.data?.level === "error" ? "error" : "info", owner ? `${entry(owner).role}: ${message}` : message) + return + } if (!sessionID) return const id = sessions.get(sessionID) if (!id) return switch (event.type) { + case "flow.swarm.status": + if (event.data?.status) patch(id, { swarm: event.data.status }) + return + case "flow.session.lost": + return lose(sessionID, String(event.data?.reason ?? "the session process ended")) case "session.next.step.started": { const messageID = event.data?.assistantMessageID const model = event.data?.model @@ -754,7 +804,7 @@ export function start( const resources = data.resources ?? [] const node = nodes.get(nodeID) - let reply: api.PermissionReply = "once" + let reply: PermissionReply = "once" if (controller.signal.aborted) { reply = "reject" } else if (gauntlet && node && (isCritic(node) || node.id === tree?.root?.id) && MUTATING.has(data.action)) { @@ -810,7 +860,7 @@ export function start( * rejection is bounded by `questionTimeout` so a run left alone still * finishes instead of hanging until the node times out. */ - async function inquire(nodeID: string, sessionID: string, data: { id: string; questions?: api.QuestionInfo[] }) { + async function inquire(nodeID: string, sessionID: string, data: { id: string; questions?: QuestionInfo[] }) { const requestID = data.id if (asked.has(requestID)) return asked.add(requestID) @@ -854,6 +904,239 @@ export function start( patch(nodeID, { activity: answers ? "answered" : "question unanswered" }) } + /** The text of a turn that was built as sections. */ + function textOf(turn: TurnText) { + return typeof turn === "string" ? turn : plainPrompt(turn) + } + + /** Keeps a fire-and-forget promise visible to the end of the run without ever letting it reject. */ + function track(promise: Promise) { + background.add(promise) + const settle = () => background.delete(promise) + promise.then(settle, settle) + } + + /** + * Hands a session back to the host. Best effort, and once: the host disposes + * the process, and nothing the run holds refers to the session afterwards. + */ + function release(sessionID: string) { + if (!held.delete(sessionID) || !api.release) return + track(api.release(sessionID).catch(() => undefined)) + } + + /** Forgets a card's session and releases it — for a card that will not be prompted into it again. */ + function abandon(id: string) { + const sessionID = nodeSession.get(id) + nodeSession.delete(id) + if (sessionID) release(sessionID) + } + + /** + * The host says a session's process is gone. The turn using it is cut short + * and failed as a lost session, which the transport budget below recovers by + * opening a new one — the same path a "session not found" from the server + * has always taken. + */ + function lose(sessionID: string, reason: string) { + dead.set(sessionID, reason) + waiting.get(sessionID)?.abort() + } + + function assertOpen(sessionID: string) { + const reason = dead.get(sessionID) + if (reason !== undefined) throw new Error(`session closed: ${reason}`) + } + + function patchMemory(id: string, part: MemoryInfo) { + patch(id, { memory: { ...entry(id).memory, ...part } }) + } + + /** + * Everything that happens to a card's task before it is sent: Prompt Uplift, + * then the memory read. Runs once per turn however many times the send is + * retried, because a retry re-sends the same turn rather than a new one. + * + * `raw:` and `agent.uplift === false` opt the card out of both: a card the + * user asked to run as written must not have a spec or a block of recalled + * text put in front of it either. + */ + async function stage(node: FlowNode, sections: PromptSection[], raw: boolean): Promise { + const original = taskOf(sections) + const reason = raw ? "raw: prefix" : node.agent.uplift === false ? "uplift is off for this card" : undefined + if (reason) { + if (deps.uplift) patch(node.id, { uplift: { status: "skipped", original, reason } }) + return { memory: "" } + } + const uplifted = deps.uplift ? await upliftTask(node, sections, original) : undefined + return { uplifted, memory: await recall(node, original) } + } + + /** + * Asks uplift for one card, and the user what to do when it cannot. + * Resolves the text to send in place of the original, or `undefined` to send + * the original; throws `UpliftError` when the card must not run. + */ + async function upliftTask(node: FlowNode, sections: PromptSection[], original: string) { + if (!original.trim()) return undefined + if (alreadyUplifted(sections)) { + patch(node.id, { uplift: { status: "skipped", original, reason: "already a spec" } }) + return undefined + } + for (let attempt = 1; ; attempt++) { + const started = Date.now() + patch(node.id, { uplift: { status: "running", original }, activity: "uplifting prompt" }) + const result = await Promise.race([ + deps + .uplift!({ + runId: log.id, + nodeId: node.id, + role: node.role, + original, + ...(nodeDir.has(node.id) ? { cwd: nodeDir.get(node.id) } : {}), + }) + .catch( + (error): UpliftResult => ({ + status: "failed", + original, + reason: api.describe(error), + elapsedMs: Date.now() - started, + }), + ), + stopped.promise, + ]) + if (!result) throw new StopError() + const info: UpliftInfo = { + status: result.status, + original, + reason: result.reason, + source: result.source, + graphId: result.graphId, + specPath: result.specPath, + nodes: result.nodes, + degraded: result.degraded, + decisions: result.decisions, + elapsedMs: result.elapsedMs, + } + // A conservative fallback spec is what the planner writes when it could + // not reach its engine: not an uplift, whatever status carries it. + const spec = result.status === "ok" && result.source !== "fallback" ? result.uplifted?.trim() : undefined + if (spec) { + patch(node.id, { uplift: { ...info, uplifted: result.uplifted } }) + return result.uplifted! + } + if (result.status === "skipped") { + patch(node.id, { uplift: info }) + activity.note( + node.id, + `uplift:${node.id}:${started}`, + `prompt uplift skipped${result.reason ? ` — ${result.reason}` : ""}`, + undefined, + "done", + ) + return undefined + } + + const reason = + result.reason ?? + (result.status === "ok" + ? result.source === "fallback" + ? "the planner fell back to a conservative spec" + : "the planner returned no spec" + : "the planner failed") + patch(node.id, { uplift: { ...info, status: "failed", reason }, activity: "uplift failed — waiting for a decision" }) + activity.note(node.id, `uplift:${node.id}:${started}`, "prompt uplift failed", reason, "error") + const choice = + hooks.onUpliftFailure && !controller.signal.aborted + ? await Promise.race([ + hooks + .onUpliftFailure({ nodeID: node.id, role: node.role, reason, elapsedMs: result.elapsedMs }) + .catch(() => "cancel" as const), + stopped.promise.then(() => "cancel" as const), + ]) + : "cancel" + if (controller.signal.aborted) throw new StopError() + if (choice === "original") { + patch(node.id, { uplift: { ...info, status: "declined", reason, declined: true } }) + return undefined + } + if (choice === "retry" && attempt < UPLIFT_ATTEMPTS) continue + throw new UpliftError(reason) + } + } + + /** + * Project memory for one card, as a block to append after its context. Fails + * open: a card runs without memory when Substrate is unreachable, and the + * card says so rather than failing. + */ + async function recall(node: FlowNode, query: string) { + if (!deps.memory || !query.trim()) return "" + patchMemory(node.id, { read: { status: "running", items: [] } }) + const result = await Promise.race([ + deps.memory + .read({ runId: log.id, nodeId: node.id, pipeline: pipeline.name, role: node.role, query }) + .catch( + (error): MemoryReadResult => ({ + status: "unavailable", + block: "", + items: [], + scopes: [], + error: api.describe(error), + elapsedMs: 0, + }), + ), + stopped.promise, + ]) + if (!result) throw new StopError() + patchMemory(node.id, { + read: { + status: result.status, + items: result.items.map((item) => ({ + scope: item.scope, + subject: item.subject, + preview: item.preview, + run: item.run, + card: item.card, + origin: item.origin, + })), + briefState: result.briefState, + error: result.error, + }, + }) + if (result.status === "unavailable") + activity.note( + node.id, + `memory:${node.id}:read`, + "project memory unavailable — running without it", + result.error, + "error", + ) + return result.status === "ok" ? result.block : "" + } + + /** + * Writes one finished card to project memory. Never awaited by the card: a + * Substrate that is slow or down must not hold a run, and a failure is + * patched onto the card where it can be seen. + */ + function remember(node: FlowNode) { + if (!deps.memory || resume[node.id] !== undefined) return + const row = entry(node.id) + if (row.status !== "done" && row.status !== "error" && row.status !== "stopped") return + const outcome = row.status + const output = outcome === "error" ? (row.error ?? "") : (outputs.get(node.id) ?? row.output ?? "") + patchMemory(node.id, { write: { status: "running" } }) + track( + deps.memory + .write({ runId: log.id, nodeId: node.id, pipeline: pipeline.name, role: node.role, outcome, output }) + .then( + (result) => patchMemory(node.id, { write: { status: result.status, subject: result.subject, error: result.error } }), + (error) => patchMemory(node.id, { write: { status: "unavailable", error: api.describe(error) } }), + ), + ) + } + /** * One turn of one card: open or reuse its session, prompt it, wait it out, * drain the transcript into `outputs`. @@ -868,7 +1151,7 @@ export function start( * re-sending a 4MB screenshot once per round is the same picture at four times * the price. */ - async function runTurn(node: FlowNode, build: (skipped: Attachment[]) => string, reuse = false) { + async function runTurn(node: FlowNode, build: (skipped: Attachment[]) => TurnText, reuse = false) { // A card carried over from an interrupted run is prompted into the session // it was already working in, whichever scheduler is asking. Consumed on the // first turn: from the second one on it is an ordinary reused session, and @@ -876,13 +1159,21 @@ export function start( const carrying = carried.delete(node.id) if (carrying) { const opening = build - build = (skipped) => `${interruptedNote()}\n\n${opening(skipped)}` + // Not a task turn, so never uplifted: the session already holds the task + // it was given, and the note alone is what resumes it. + build = (skipped) => `${interruptedNote()}\n\n${textOf(opening(skipped))}` } let sessionID = reuse || carrying ? nodeSession.get(node.id) : undefined - // The log has to name the session a carried card is in. Nothing else writes - // it — the create path is skipped — so without this the run reports a card - // with no session while it is answering in one, and the next interruption - // has nothing left to carry forward. + // The session an earlier run left open, to be re-attached by the host on + // this turn's first `createSession` rather than prompted into blind: a host + // that restarted since still has the session's files, and only it can say. + let attach = carrying ? sessionID : undefined + /** What uplift and memory decided for this turn, kept so a retry re-sends the same text instead of asking again. */ + let staged: Staged | undefined + // The log has to name the session a carried card is in from the first + // moment, not only once the host has answered the re-attach: without it a + // run interrupted again in between reports a card with no session while it + // is answering in one, and has nothing left to carry forward. if (carrying && sessionID) patch(node.id, { sessionID }) // Whether this card's files have already reached the session. A reused one // holds them; a retry after a refused prompt does not, so the attachments @@ -897,28 +1188,15 @@ export function start( // Only this turn's tool failures count; a card is prompted into the session // it already holds, so every earlier turn's are still on the stream. const turnStarted = Date.now() + /** Cut short by `flow.session.lost`, so the wait below ends when the process does. */ + const turnWait = new AbortController() + controller.signal.addEventListener("abort", () => turnWait.abort(), { once: true, signal: turnWait.signal }) patch(node.id, { status: "running", ...(opened ? {} : { started: Date.now() }), activity: opened ? "continuing session" : "starting session", }) try { - if (!sessionID) { - const session = await api.createSession({ - agent: node.agent.name, - model: node.agent.model, - // Fixed for the life of the session, which is why a card keeps the - // tree it was first given rather than moving between batches. - directory: nodeDir.get(node.id), - }) - sessionID = session.id - nodeSession.set(node.id, session.id) - sessions.set(session.id, node.id) - patch(node.id, { sessionID: session.id }) - } - active.add(sessionID) - patch(node.id, { activity: "queued" }) - // Run-level files first, then the node's own pins. A file the node's // model has no modality for is withheld and named in the text instead, // so one blind model in the middle of a chain does not stop the run. @@ -945,19 +1223,63 @@ export function start( // no shell has nothing to redirect and is not told. const needsScratch = !!scratchDir && !toldScratch.has(node.id) && toolMap(node.agent.tools).bash !== false if (needsScratch) toldScratch.add(node.id) - const text = [ - build(skipped), + const appended = [ ...(model && !api.accepts(model, "image/png") ? [imageBlindNote()] : []), ...(needsScratch ? [scratchNote(scratchDir)] : []), - ].join("\n\n") + ] + const turn = build(skipped) + // Only a task turn — the card's own instruction, handed over as + // sections — goes through uplift and memory. A protocol turn is a + // string, and cannot reach either by construction. + let text: string + if (typeof turn === "string") text = [turn, ...appended].join("\n\n") + else { + const task = stripRaw(turn) + staged ??= await stage(node, task.sections, task.raw) + text = [ + staged.uplifted + ? upliftedPrompt(task.sections, staged.uplifted, appended) + : [plainPrompt(task.sections), ...appended].join("\n\n"), + ...(staged.memory ? [staged.memory] : []), + ].join("\n\n") + } + + // The session is opened only now that the text is settled, so a card + // whose uplift is cancelled never costs a process. + if (!sessionID || attach) { + const session = await api.createSession({ + runId: log.id, + nodeId: node.id, + card: cardSpec(pipeline, node), + model: node.agent.model, + // Fixed for the life of the session, which is why a card keeps the + // tree it was first given rather than moving between batches. + directory: nodeDir.get(node.id), + permissions: policy, + swarm: swarmOf(pipeline, node), + ...(attach ? { reuse: attach } : {}), + }) + attach = undefined + sessionID = session.id + nodeSession.set(node.id, session.id) + sessions.set(session.id, node.id) + held.add(session.id) + patch(node.id, { sessionID: session.id }) + } + active.add(sessionID) + waiting.set(sessionID, turnWait) + patch(node.id, { activity: "queued" }) patch(node.id, { prompt: text }) + assertOpen(sessionID) await api.prompt(sessionID, text, sendable) delivered = true if (controller.signal.aborted) throw new StopError() - await api.waitForIdle(sessionID, { signal: controller.signal, timeout: nodeTimeout }) + assertOpen(sessionID) + await api.waitForIdle(sessionID, { signal: turnWait.signal, timeout: nodeTimeout }) active.delete(sessionID) if (controller.signal.aborted) throw new StopError() + assertOpen(sessionID) const result = await api.transcript(sessionID) if (result.error) throw new Error(result.error) @@ -1056,16 +1378,21 @@ export function start( patch(node.id, { status: "stopped", activity: undefined, finished: Date.now() }) return } - const reason = api.describe(error) + // Uplift's own refusal is a conclusion, not transport: asking again is + // the user's decision (`onUpliftFailure`), never a silent retry, and its + // text must not be read by the classifiers below. + const own = error instanceof UpliftError + const reason = own ? error.message : api.describe(error) // A session the server has lost fails every turn prompted into it, so // it is forgotten before the retry decision: the next attempt creates a // new one instead of re-sending into a dead handle. The card keeps its // place in the run either way — only its memory of the turn is gone. - const lost = sessionLost(reason) + const lost = !own && sessionLost(reason) if (sessionID && lost) { nodeSession.delete(node.id) sessions.delete(sessionID) active.delete(sessionID) + release(sessionID) activity.note( node.id, `session:${node.id}:${turnStarted}`, @@ -1080,12 +1407,12 @@ export function start( // turn, or the connection to it dropped, before it produced anything — // so the same prompt goes back into the same session after a wait // rather than costing the run a card. - const refused = rateLimited(reason) + const refused = !own && rateLimited(reason) // A replaced session is retried on the transport budget: opening a new // one is only a recovery if something is then sent into it, and the // count bounds the case where every new session is lost as fast as it // is made. - const dropped = !refused && (lost || transient(reason)) + const dropped = !own && !refused && (lost || transient(reason)) const attempt = refused ? refusals : drops const allowed = refused ? RATE_LIMIT_RETRIES : TRANSIENT_RETRIES if ((refused || dropped) && attempt < allowed && rateLimitBackoff > 0) { @@ -1123,6 +1450,8 @@ export function start( finished: Date.now(), }) } finally { + turnWait.abort() + if (sessionID) waiting.delete(sessionID) // Tokens are spent whether the node finished, failed or was stopped, so // the bill is read back in every case rather than only on success. if (sessionID) await reconcile(node.id, sessionID) @@ -1236,7 +1565,10 @@ export function start( pipe === "direct" ? sources : [...ancestors(pipeline, node.id)].sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0)) - await runTurn(node, (skipped) => buildPrompt(pipeline, node, context, outputs, input, skipped)) + await runTurn(node, (skipped) => buildSections(pipeline, node, context, outputs, input, skipped)) + // A pipeline card runs once, so nothing will prompt into its session again. + remember(node) + abandon(node.id) } /** @@ -1249,7 +1581,16 @@ export function start( async function runPeer(node: FlowNode, round: number, peers: Map) { if (failed.has(node.id)) return activity.note(node.id, `round:${node.id}:${round}`, `round ${round} of ${roundsOf(pipeline)}`, undefined, "done") - await runTurn(node, (skipped) => swarmPrompt(pipeline, node, round, peers, input, skipped), round > 1) + await runTurn( + node, + (skipped) => + round > 1 ? swarmPrompt(pipeline, node, round, peers, input, skipped) : swarmSections(pipeline, node, input, skipped), + round > 1, + ) + // A peer's card is finished after its last round, or the moment it fell out. + if (round < roundsOf(pipeline) && !failed.has(node.id) && !controller.signal.aborted) return + remember(node) + abandon(node.id) } /** @@ -1263,22 +1604,32 @@ export function start( * * Returns the answer, or undefined when the card failed or the run stopped. */ - async function orchestrate(node: FlowNode, task: string, first: boolean): Promise { + async function orchestrate(node: FlowNode, task: string, first: boolean) { + const answer = await orchestration(node, task, first) + remember(node) + // The root's answer is the run's, so nothing prompts into its session + // again. A subtree orchestrator can be dispatched a second time, and is + // prompted into the session it holds, so it lives until the run ends. + if (first) abandon(node.id) + return answer + } + + async function orchestration(node: FlowNode, task: string, first: boolean): Promise { const children = tree!.children(node.id) const budget = gauntlet ? GAUNTLET_DISPATCHES : dispatchesOf(pipeline) // A card dispatched a second time is prompted into the session it already // holds, so it still has its briefing and its earlier task — only the job // is new. const returning = nodeSession.has(node.id) - let build = (skipped: Attachment[]) => + let build: (skipped: Attachment[]) => TurnText = (skipped) => returning ? reassignPrompt(task) : first - ? orchestratorPrompt(pipeline, node, input, skipped) + ? orchestratorSections(pipeline, node, input, skipped) : // Only a card with children reaches here; a leaf goes through // `runSubagent`. So this is always somebody's subagent that must // itself speak the protocol. - subOrchestratorPrompt(pipeline, node, parentOf(node.id)!, task, input, skipped) + subOrchestratorSections(pipeline, node, parentOf(node.id)!, task, input, skipped) let spent = 0 /** * Re-asks used on the protocol, not on the work. @@ -1514,7 +1865,7 @@ export function start( // It costs the cached prefix and re-sends the reference files, and // that is the price of the method. if (gauntlet && isCritic(child)) { - nodeSession.delete(child.id) + abandon(child.id) consumed.delete(child.id) } declared.set(child.id, assignment.files ?? []) @@ -1846,9 +2197,9 @@ export function start( * than killing a run over one flaky request. */ async function decide(node: FlowNode, children: string[]) { - // Skipped entirely while the tool channel is parked: no MCP tool can reach - // a v2 session in this fork, so every scan would be a request per turn that - // cannot find anything. See `MCP_REACHES_SESSIONS`. + // Skipped when the channel is switched off for the run (`toolChannel`): the + // host then offers no dispatch tools, and every scan would be a request per + // turn that cannot find anything. See `MCP_REACHES_SESSIONS`. const calls = toolChannel ? ((await api.sessionCalls?.(nodeSession.get(node.id)!).catch(() => [])) ?? []) : [] const seen = consumed.get(node.id) ?? new Set() let decision: ReturnType | undefined @@ -1875,10 +2226,13 @@ export function start( returning ? reassignPrompt(task) : critic - ? criticPrompt(pipeline, node, parent, task, input, skipped) - : subagentPrompt(pipeline, node, parent, task, input, skipped), + ? // A critic judges; it is never uplifted, and never given memory + // that could colour the verdict. + criticPrompt(pipeline, node, parent, task, input, skipped) + : subagentSections(pipeline, node, parent, task, input, skipped), true, ) + remember(node) if (failed.has(node.id)) return undefined return outputs.get(node.id) } @@ -1924,7 +2278,7 @@ export function start( // A verdict on work the critic itself helped produce is the failure the // fresh session exists to prevent, and in a pipeline the reviewer has // usually just run. - nodeSession.delete(critic.id) + abandon(critic.id) consumed.delete(critic.id) activity.note(critic.id, `verify:${critic.id}`, "verifying the run's result", undefined, "done") @@ -2002,43 +2356,6 @@ export function start( return log } - const gap = await unknownAgents(dispatching, api) - if (gap) { - // The fix is always the same for the stale-config case — restart the - // engine — so the message carries the command for *this* host rather - // than the generic name of a binary that may not be on PATH, or may be - // a different version than the one this checkout runs. - const serve = gap.scope === "config" ? await deps.serveStatus?.().catch(() => undefined) : undefined - const restart = serve - ? serve.managed - ? `use the restart button in the titlebar, or run: ${serve.command}` - : `stop \`opencode serve\` where it is running (Ctrl+C in that window), then run this from the OpenFlow repo root: -${serve.command}` - : "restart `opencode serve` where it is running" - for (const node of gap.nodes) { - failed.add(node.id) - patch(node.id, { - status: "error", - error: - gap.scope === "directory" - ? `the server does not know an agent named "${node.agent.name}", and it knows no agents at all for this project — not even the built-in ones. That is what a project folder which is not a git repository looks like from here. Run \`git init\` in it, or point OPENFLOW_PROJECT at a repository. Restarting the engine will not change this.` - : `the server does not know an agent named "${node.agent.name}" — it reads its config once at boot, so a merged agent stays invisible until it restarts. ${restart}`, - finished: Date.now(), - }) - } - hooks.onNotice?.( - "error", - gap.scope === "directory" - ? `unknown agent on ${gap.nodes.map((node) => node.role).join(", ")} — the project folder is not a git repository, so it reports no agents at all` - : `unknown agent on ${gap.nodes.map((node) => node.role).join(", ")} — the engine needs a restart to see it`, - ) - // Deliberately not opened for the directory case: that dialog offers a - // restart, and a restart is exactly what does not fix it. - if (gap.scope === "config") hooks.onEngineStale?.() - log.status = "error" - return log - } - // Opened once, before anything is dispatched, so every card's first turn // can carry it. A host that cannot make one simply never mentions it. scratchDir = (await deps.scratch?.(log.id).catch(() => null))?.path ?? "" @@ -2067,8 +2384,11 @@ ${serve.command}` peer: (id, round) => runPeer(nodes.get(id)!, round, said), synthesise: () => runTurn(swarm.synthesizers[0], (skipped) => - synthesisPrompt(pipeline, swarm.synthesizers[0], outputs, input, skipped), - ), + synthesisSections(pipeline, swarm.synthesizers[0], outputs, input, skipped), + ).then(() => { + remember(swarm.synthesizers[0]) + abandon(swarm.synthesizers[0].id) + }), }) else if (tree) await orchestrate(tree.root, input, true) else await runPipeline(validation.layers, limit, controller.signal, (id) => runNode(nodes.get(id)!), measured) @@ -2105,6 +2425,17 @@ ${serve.command}` // them, and a stack of stale worktrees in the user's repository is the // thing that would make this feature not worth having. if (opened.size && deps.worktrees) await deps.worktrees.cleanup(log.id, [...opened.values()]).catch(() => {}) + // Every session the run still holds goes back to the host: the processes + // are the host's to dispose, and a run that ended leaves nothing for them + // to wait on. Memory writes are given a moment to land so the log shows + // what became of them, but never longer than that. + for (const sessionID of [...held]) release(sessionID) + if (background.size) { + const grace = Promise.withResolvers() + const timer = setTimeout(grace.resolve, backgroundGrace) + await Promise.race([Promise.allSettled([...background]), grace.promise]) + clearTimeout(timer) + } controller.abort() void bus hooks.onRun?.(log) @@ -2129,6 +2460,13 @@ class StopError extends Error { } } +/** Prompt Uplift failed and the card was not allowed to run on the original. */ +class UpliftError extends Error { + constructor(reason: string) { + super(`prompt uplift failed: ${reason}`) + } +} + /** * The `pipeline` mode scheduler: topological layers, one layer at a time. * @@ -2217,49 +2555,3 @@ async function pool(items: T[], limit: number, signal: AbortSignal, work: (it function unknownModels(nodes: FlowNode[], catalog: Map) { return nodes.filter((node) => node.agent.model && !catalog.has(node.agent.model)) } - -/** - * Nodes pointing at an agent the server has never heard of, and which of the - * two reasons it is. - * - * The server reads a project's opencode.json once and caches it, so agents - * merged after it started are invisible until it restarts. Running anyway is - * the worst failure mode available: the session comes up with an empty - * permission ruleset and every tool call dies with "Unable to read ...", which - * reads like a broken model rather than a stale config. - * - * An **empty** list is a different question, and answering it as the same one - * costs an hour: the built-ins are gone too, which no config merge can cause. - * That is what a directory the server does not recognise as a project looks - * like — most often one that is simply not a git repository. The run itself may - * be perfectly fine, because a session's config is resolved from the engine's - * own cwd rather than from `OPENFLOW_PROJECT`, so the unscoped list is asked - * before anything is failed and the cards proceed when the names are in it. - * Only when they are not is the run stopped, and then the fix is `git init`, - * not a restart. - */ -async function unknownAgents(nodes: FlowNode[], api: EngineDeps["api"]) { - const named = nodes.filter((node) => node.agent.name) - if (!named.length) return undefined - const available = await api - .agents() - .then((list) => new Set(list.map((agent) => agent.id))) - .catch(() => undefined) - if (!available) return undefined - - if (!available.size) { - const drain = await api - .agentsUnscoped?.() - .then((list) => new Set(list.map((agent) => agent.id))) - .catch(() => undefined) - // No unscoped read available: the host is a test double or an older build, - // and guessing which of the two cases this is would be worse than the - // stale-config message that shipped before this check existed. - if (!drain) return { scope: "config" as const, nodes: named } - const missing = named.filter((node) => !drain.has(node.agent.name!)) - return missing.length ? { scope: "directory" as const, nodes: missing } : undefined - } - - const missing = named.filter((node) => !available.has(node.agent.name!)) - return missing.length ? { scope: "config" as const, nodes: missing } : undefined -} diff --git a/packages/flow/src/server/memory-client.test.ts b/packages/flow/src/server/memory-client.test.ts new file mode 100644 index 0000000000..84e607ea9d --- /dev/null +++ b/packages/flow/src/server/memory-client.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import type { Server } from "bun" +import { MEMORY_ROUTES, type MemoryStatus } from "../../shared/memory" +import { createMemoryClient, requestJson, type MemoryClient } from "./memory-client" + +type Seen = { method: string; path: string; search: string; body: unknown } + +let seen: Seen[] +let reply: (request: Request, path: string) => Response +let server: Server +let client: MemoryClient + +const status: MemoryStatus = { + enabled: true, + reachable: true, + url: "127.0.0.1:8787", + surface: "omp", + project: "swcstudio/flow", + retention: { ttl: "30d", deletable: false, note: "Substrate cannot delete entries." }, + problems: [], +} + +beforeEach(() => { + seen = [] + reply = () => Response.json({}) + server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(request) { + const url = new URL(request.url) + const text = await request.text() + seen.push({ method: request.method, path: url.pathname, search: url.search, body: text ? JSON.parse(text) : undefined }) + return reply(request, url.pathname) + }, + }) + client = createMemoryClient({ base: `http://127.0.0.1:${server.port}` }) +}) + +afterEach(async () => { + await server.stop(true) +}) + +describe("memory client", () => { + test("status GETs the status route and returns the body as sent", async () => { + reply = () => Response.json(status) + const result = await client.status() + expect(result).toEqual({ ok: true, value: status }) + expect(seen).toEqual([{ method: "GET", path: MEMORY_ROUTES.status, search: "", body: undefined }]) + }) + + test("entries sends the pipeline, and run and limit only when given", async () => { + reply = () => Response.json({ status: "empty", items: [] }) + await client.entries({ pipeline: "my pipeline/1" }) + await client.entries({ pipeline: "p", runId: "run-9", limit: 25 }) + expect(seen.map((entry) => `${entry.method} ${entry.path}${entry.search}`)).toEqual([ + `GET ${MEMORY_ROUTES.entries}?pipeline=my+pipeline%2F1`, + `GET ${MEMORY_ROUTES.entries}?pipeline=p&run=run-9&limit=25`, + ]) + }) + + test("entries returns rows with their retraction markers", async () => { + const row = { id: "e1", scope: "repo:swcstudio/flow", kind: "fact", subject: "openflow/p/r/c", preview: "done", origin: "search", retracted: true } + reply = () => Response.json({ status: "ok", items: [row] }) + const result = await client.entries({ pipeline: "p" }) + expect(result.ok && result.value.items[0]).toEqual(row as never) + }) + + test("forget POSTs the request as JSON and returns the count", async () => { + reply = () => Response.json({ status: "retracted", retracted: 3 }) + const result = await client.forget({ pipeline: "p", runId: "r" }) + expect(result).toEqual({ ok: true, value: { status: "retracted", retracted: 3 } }) + expect(seen).toEqual([{ method: "POST", path: MEMORY_ROUTES.forget, search: "", body: { pipeline: "p", runId: "r" } }]) + }) + + test("an error body's message is passed on, without the status noise", async () => { + reply = () => Response.json({ error: "`pipeline` is required", code: "bad-request" }, { status: 400 }) + expect(await client.entries({ pipeline: "" })).toEqual({ ok: false, error: "`pipeline` is required" }) + }) + + test("a non-JSON failure falls back to the status code", async () => { + reply = () => new Response("bad gateway", { status: 502 }) + expect(await client.status()).toEqual({ ok: false, error: "The request failed (502)." }) + }) + + test("a 200 that is not a JSON object is a failure, not a value", async () => { + reply = () => new Response("nope", { status: 200 }) + const result = await client.status() + expect(result.ok).toBe(false) + }) + + test("a host that is down resolves instead of throwing, with an actionable sentence", async () => { + await server.stop(true) + const result = await client.status() + expect(result.ok).toBe(false) + expect(!result.ok && result.error).toContain("did not answer") + expect(!result.ok && result.error).toContain("refresh") + server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => Response.json({}) }) + }) + + test("a fetch that rejects is the same failure", async () => { + const result = await requestJson(() => Promise.reject(new Error("socket hang up")), "http://x/y") + expect(result).toEqual({ + ok: false, + error: "The OpenFlow host did not answer. Check that OpenFlow is still running, then refresh.", + }) + }) +}) diff --git a/packages/flow/src/server/memory-client.ts b/packages/flow/src/server/memory-client.ts new file mode 100644 index 0000000000..48cb10415e --- /dev/null +++ b/packages/flow/src/server/memory-client.ts @@ -0,0 +1,81 @@ +import { + MEMORY_ROUTES, + type MemoryEntriesResult, + type MemoryForgetRequest, + type MemoryForgetResult, + type MemoryStatus, +} from "../../shared/memory" + +/** + * Browser client for the read-side `MEMORY_ROUTES` (status, entries, forget). + * Every call resolves to a result instead of throwing: the memory panel renders + * each outcome in place, and "the host did not answer" must read differently from + * "Substrate is unreachable", which the host reports inside a 200 body. + */ +export type ClientResult = { ok: true; value: T } | { ok: false; error: string } + +export type Fetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise + +export type MemoryClientOptions = { + /** Origin prefix; empty in the browser, `http://127.0.0.1:` in tests. */ + base?: string + fetch?: Fetcher +} + +export type MemoryClient = { + status(): Promise> + entries(query: { pipeline: string; runId?: string; limit?: number }): Promise> + forget(request: MemoryForgetRequest): Promise> +} + +const NO_ANSWER = "The OpenFlow host did not answer. Check that OpenFlow is still running, then refresh." + +/** The one transport for the memory panel and the settings dialog's swarm check: JSON in, a result out. */ +export async function requestJson( + doFetch: Fetcher, + url: string, + init?: { method: string; body?: unknown }, +): Promise> { + let response: Response + try { + response = await doFetch(url, { + method: init?.method ?? "GET", + headers: init?.body === undefined ? undefined : { "content-type": "application/json" }, + body: init?.body === undefined ? undefined : JSON.stringify(init.body), + }) + } catch { + return { ok: false, error: NO_ANSWER } + } + const payload: unknown = await response.json().catch(() => undefined) + if (!response.ok) { + const reason = + typeof payload === "object" && payload !== null && "error" in payload && typeof payload.error === "string" + ? payload.error + : `The request failed (${response.status}).` + return { ok: false, error: reason } + } + if (typeof payload !== "object" || payload === null) { + return { ok: false, error: `The OpenFlow host sent an unreadable answer (${response.status}).` } + } + // The host is this app's own server: its 2xx bodies are the types in shared/. + return { ok: true, value: payload as T } +} + +export function createMemoryClient(options: MemoryClientOptions = {}): MemoryClient { + const base = options.base ?? "" + const doFetch = options.fetch ?? ((input, init) => fetch(input, init)) + + return { + status: () => requestJson(doFetch, `${base}${MEMORY_ROUTES.status}`), + entries(query) { + const params = new URLSearchParams({ pipeline: query.pipeline }) + if (query.runId) params.set("run", query.runId) + if (query.limit !== undefined) params.set("limit", String(query.limit)) + return requestJson(doFetch, `${base}${MEMORY_ROUTES.entries}?${params}`) + }, + forget: (request) => requestJson(doFetch, `${base}${MEMORY_ROUTES.forget}`, { method: "POST", body: request }), + } +} + +/** The browser's client: same-origin, global fetch. */ +export const memory = createMemoryClient() diff --git a/packages/flow/src/server/omp-config.test.ts b/packages/flow/src/server/omp-config.test.ts new file mode 100644 index 0000000000..c402cfcfe3 --- /dev/null +++ b/packages/flow/src/server/omp-config.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { DEFAULT_OMP_SETTINGS, OMP_ROUTES } from "../../shared/omp" +import { createOmpClient, type OmpClient } from "./omp-config" + +type Seen = { method: string; path: string; body: unknown } + +let seen: Seen[] +let reply: (request: Request, path: string) => Response +let server: ReturnType +let client: OmpClient + +beforeEach(() => { + seen = [] + reply = () => Response.json({}) + server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(request) { + const path = new URL(request.url).pathname + const text = await request.text() + seen.push({ method: request.method, path, body: text ? JSON.parse(text) : undefined }) + return reply(request, path) + }, + }) + client = createOmpClient({ base: `http://127.0.0.1:${server.port}` }) +}) + +afterEach(async () => { + await server.stop(true) +}) + +describe("omp client", () => { + test("status and settings are returned as sent", async () => { + reply = (_request, path) => + Response.json( + path === OMP_ROUTES.status + ? { available: true, problems: [], warnings: [], profile: "openflow", minVersion: "18.12.2", checkedAt: 1 } + : { settings: DEFAULT_OMP_SETTINGS, effective: {}, warnings: [] }, + ) + const status = await client.status() + expect(status.ok && status.value.profile).toBe("openflow") + const settings = await client.settings() + expect(settings.ok && settings.value.settings).toEqual(DEFAULT_OMP_SETTINGS) + expect(seen.map((entry) => `${entry.method} ${entry.path}`)).toEqual([`GET ${OMP_ROUTES.status}`, `GET ${OMP_ROUTES.settings}`]) + }) + + test("saveSettings PUTs the patch as the body and resetSettings POSTs", async () => { + reply = () => Response.json({ settings: DEFAULT_OMP_SETTINGS, effective: {}, warnings: [] }) + await client.saveSettings({ profile: "work", uplift: { hitl: true } }) + await client.resetSettings() + expect(seen).toEqual([ + { method: "PUT", path: OMP_ROUTES.settings, body: { profile: "work", uplift: { hitl: true } } }, + { method: "POST", path: OMP_ROUTES.settingsReset, body: undefined }, + ]) + }) + + test("a 422 carries the server's field errors", async () => { + reply = () => + Response.json( + { error: "settings were not saved", errors: [{ path: "uplift.timeoutMs", message: "too small" }, { nonsense: true }] }, + { status: 422 }, + ) + const result = await client.saveSettings({ uplift: { timeoutMs: 1 } }) + expect(result).toEqual({ ok: false, status: 422, errors: [{ path: "uplift.timeoutMs", message: "too small" }] }) + }) + + test("an error body without a field list becomes one unattached error", async () => { + reply = () => Response.json({ error: "omp was not found", code: "omp-unavailable" }, { status: 503 }) + expect(await client.config()).toEqual({ ok: false, status: 503, errors: [{ path: "", message: "omp was not found" }] }) + }) + + test("a body that is not JSON still yields a readable error with the status", async () => { + reply = () => new Response("bad gateway", { status: 502 }) + const result = await client.providers() + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.status).toBe(502) + expect(result.errors[0]?.message).toContain("502") + } + }) + + test("config unwraps the entries list", async () => { + reply = () => Response.json({ entries: [{ key: "a", type: "boolean", description: "", value: true, editable: true }] }) + const result = await client.config() + expect(result.ok && result.value.map((entry) => entry.key)).toEqual(["a"]) + }) + + test("setConfig percent-encodes the key and sends { value }; resetConfig POSTs /reset", async () => { + reply = () => Response.json({ entry: { key: "a b/c", type: "string", description: "", value: "x", editable: true } }) + const set = await client.setConfig("a b/c", "x") + const reset = await client.resetConfig("a b/c") + expect(set.ok && set.value.key).toBe("a b/c") + expect(reset.ok).toBe(true) + expect(seen).toEqual([ + { method: "PUT", path: `${OMP_ROUTES.config}/a%20b%2Fc`, body: { value: "x" } }, + { method: "POST", path: `${OMP_ROUTES.config}/a%20b%2Fc/reset`, body: undefined }, + ]) + }) + + test("providers unwraps the rows", async () => { + reply = () => Response.json({ providers: [{ id: "a", name: "A", available: true, authenticated: false }] }) + const result = await client.providers() + expect(result.ok && result.value).toEqual([{ id: "a", name: "A", available: true, authenticated: false }]) + }) + + test("a host that is not listening is status 0, never a throw", async () => { + const closed = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("") }) + const base = `http://127.0.0.1:${closed.port}` + await closed.stop(true) + const result = await createOmpClient({ base }).status() + expect(result).toEqual({ ok: false, status: 0, errors: [{ path: "", message: "The OpenFlow host did not answer." }] }) + }) +}) diff --git a/packages/flow/src/server/omp-config.ts b/packages/flow/src/server/omp-config.ts new file mode 100644 index 0000000000..9c9704e98f --- /dev/null +++ b/packages/flow/src/server/omp-config.ts @@ -0,0 +1,96 @@ +import { + OMP_ROUTES, + type OmpConfigEntry, + type OmpProviderRow, + type OmpSettingsPatch, + type OmpSettingsView, + type OmpStatusView, + type ValidationError, +} from "../../shared/omp" + +/** + * Browser client for `OMP_ROUTES`. Every call resolves to a result instead of + * throwing, because the dialog renders each outcome (host down, refused value, + * omp missing) in place rather than as a toast. + * + * `status` is the HTTP status, or 0 when the host never answered: the one case + * the UI shows as "disconnected" instead of as an error from omp. + */ +export type OmpResult = { ok: true; value: T } | { ok: false; errors: ValidationError[]; status: number } + +export type OmpClientOptions = { + /** Origin prefix; empty in the browser, `http://127.0.0.1:` in tests. */ + base?: string + fetch?: typeof fetch +} + +export type OmpClient = { + status(): Promise> + settings(): Promise> + saveSettings(patch: OmpSettingsPatch): Promise> + resetSettings(): Promise> + config(): Promise> + setConfig(key: string, value: unknown): Promise> + resetConfig(key: string): Promise> + providers(): Promise> +} + +function readErrors(body: unknown, status: number): ValidationError[] { + if (typeof body === "object" && body !== null) { + if ("errors" in body && Array.isArray(body.errors)) { + const errors = body.errors.filter( + (item): item is ValidationError => + typeof item === "object" && item !== null && typeof item.path === "string" && typeof item.message === "string", + ) + if (errors.length) return errors + } + if ("error" in body && typeof body.error === "string") return [{ path: "", message: body.error }] + } + return [{ path: "", message: `The request failed (${status}).` }] +} + +export function createOmpClient(options: OmpClientOptions = {}): OmpClient { + const base = options.base ?? "" + const doFetch = options.fetch ?? ((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init)) + + async function request( + route: string, + method: string, + body?: unknown, + pick?: (payload: Payload) => T, + ): Promise> { + let response: Response + try { + response = await doFetch(`${base}${route}`, { + method, + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + } catch { + return { ok: false, status: 0, errors: [{ path: "", message: "The OpenFlow host did not answer." }] } + } + const payload: unknown = await response.json().catch(() => undefined) + if (!response.ok) return { ok: false, status: response.status, errors: readErrors(payload, response.status) } + // The host is this app's own server: its 2xx bodies are the types in `OMP_ROUTES`' contract. + return { ok: true, value: pick ? pick(payload as Payload) : (payload as T) } + } + + const key = (name: string) => `${OMP_ROUTES.config}/${encodeURIComponent(name)}` + + return { + status: () => request(OMP_ROUTES.status, "GET"), + settings: () => request(OMP_ROUTES.settings, "GET"), + saveSettings: (patch) => request(OMP_ROUTES.settings, "PUT", patch), + resetSettings: () => request(OMP_ROUTES.settingsReset, "POST"), + config: () => + request<{ entries: OmpConfigEntry[] }, OmpConfigEntry[]>(OMP_ROUTES.config, "GET", undefined, (payload) => payload.entries), + setConfig: (name, value) => + request<{ entry: OmpConfigEntry }, OmpConfigEntry>(key(name), "PUT", { value }, (payload) => payload.entry), + resetConfig: (name) => + request<{ entry: OmpConfigEntry }, OmpConfigEntry>(`${key(name)}/reset`, "POST", undefined, (payload) => payload.entry), + providers: () => + request<{ providers: OmpProviderRow[] }, OmpProviderRow[]>(OMP_ROUTES.providers, "GET", undefined, (payload) => payload.providers), + } +} + +export const omp: OmpClient = createOmpClient() diff --git a/packages/flow/src/server/providers.test.ts b/packages/flow/src/server/providers.test.ts index 5b65ade07c..9ed55b1aed 100644 --- a/packages/flow/src/server/providers.test.ts +++ b/packages/flow/src/server/providers.test.ts @@ -1,451 +1,187 @@ -import { describe, expect, test } from "bun:test" -import type { Integration } from "./client" +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import type { ModelInfo } from "../../shared/engine" +import { ENGINE_BASE } from "../../shared/engine" +import { OMP_ROUTES, type OmpProviderRow } from "../../shared/omp" +import * as client from "./client" import { availableModels, - freeModels, groupMatches, - isActive, - isConnected, - isFreeModel, providerRows, readyRows, - runnable, searchModels, searchProviders, - splitProviders, - suggestedFreeDefault, unlockedRows, - usableCount, } from "./providers" -function integration(id: string, options: { name?: string; env?: string[]; credentials?: string[] } = {}): Integration { - return { - id, - name: options.name ?? id, - methods: [{ type: "key" }, ...(options.env ? [{ type: "env", names: options.env }] : [])], - connections: (options.credentials ?? []).map((credential) => ({ type: "credential", id: credential })), - } -} - -const catalog = [ - integration("groq", { name: "Groq", env: ["GROQ_API_KEY"], credentials: ["cred_groq"] }), - integration("anthropic", { name: "Anthropic", env: ["ANTHROPIC_API_KEY"] }), - integration("openrouter", { name: "OpenRouter", env: ["OPENROUTER_API_KEY"], credentials: ["cred_or"] }), - integration("opencode", { name: "OpenCode Zen" }), +const models: ModelInfo[] = [ + { providerID: "anthropic", id: "claude-opus-5", name: "Claude Opus 5", reasoning: true, contextWindow: 200_000 }, + { providerID: "anthropic", id: "claude-haiku-5", name: "Claude Haiku 5" }, + { providerID: "openai", id: "gpt-5", name: "GPT-5" }, + { providerID: "openrouter", id: "meta/llama-4" }, + { providerID: "local-llm", id: "tiny" }, ] -const compatible = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://example.test/v1" } -const groqSdk = { type: "aisdk", package: "@ai-sdk/groq" } - -const models = [ - { id: "llama-4", providerID: "groq", name: "Llama 4", api: groqSdk }, - { id: "kimi-k2", providerID: "groq", name: "Kimi K2", api: compatible }, - { id: "nemotron", providerID: "opencode", name: "Nemotron", api: compatible }, +const providers: OmpProviderRow[] = [ + { id: "anthropic", name: "Anthropic", available: true, authenticated: true }, + { id: "openai", name: "OpenAI", available: true, authenticated: false }, + { id: "openrouter", name: "OpenRouter", available: true, authenticated: true }, + { id: "xai", name: "xAI", available: true, authenticated: true }, + { id: "zai", name: "Z.ai", available: true, authenticated: false }, ] -describe("runnable", () => { - test("accepts the three shapes the session runner actually routes", () => { - expect(runnable({ type: "aisdk", package: "@ai-sdk/openai" })).toBe(true) - expect(runnable({ type: "aisdk", package: "@ai-sdk/anthropic" })).toBe(true) - expect(runnable(compatible)).toBe(true) - }) - - test("rejects provider-specific packages, which fail with UnsupportedApiError", () => { - expect(runnable(groqSdk)).toBe(false) - expect(runnable({ type: "aisdk", package: "@openrouter/ai-sdk-provider" })).toBe(false) - expect(runnable({ type: "aisdk", package: "@ai-sdk/google" })).toBe(false) - }) - - test("a profiled provider is routed by id, whatever its package says", () => { - expect(runnable(groqSdk, "groq")).toBe(true) - expect(runnable({ type: "aisdk", package: "@openrouter/ai-sdk-provider" }, "openrouter")).toBe(true) - // The profile supplies the base URL these entries never carry. - expect(runnable({ type: "aisdk", package: "@ai-sdk/openai-compatible" }, "cerebras")).toBe(true) - }) - - test("the profile is keyed by provider, not by package", () => { - // No profile exists for these, so the package is all they have to stand on. - expect(runnable({ type: "aisdk", package: "@ai-sdk/google" }, "opencode")).toBe(false) - expect(runnable(groqSdk, "nvidia")).toBe(false) - expect(runnable({ type: "native", url: "https://example.test" }, "groq")).toBe(false) - }) - - test("openai-compatible without a base URL has nowhere to send the request", () => { - expect(runnable({ type: "aisdk", package: "@ai-sdk/openai-compatible" })).toBe(false) - }) - - test("native and missing api shapes are not routed", () => { - expect(runnable({ type: "native", url: "https://example.test" })).toBe(false) - expect(runnable(undefined)).toBe(false) - }) -}) - describe("providerRows", () => { - test("joins the catalog with the models the server would actually accept", () => { - const rows = providerRows(catalog, models) - const groq = rows.find((row) => row.id === "groq")! - expect(groq.models.map((model) => model.value)).toEqual(["groq/kimi-k2", "groq/llama-4"]) - expect(groq.env).toEqual(["GROQ_API_KEY"]) - expect(isActive(groq)).toBe(true) - expect(isConnected(groq)).toBe(true) - }) - - test("counts only the models this build can dispatch, and lists them first", () => { - const rows = providerRows(catalog, models) - const groq = rows.find((row) => row.id === "groq")! - // Both count: groq is in the profile table, so its own SDK package routes too. - expect(usableCount(groq)).toBe(2) - expect(groq.models.map((model) => [model.id, model.runnable])).toEqual([ - ["kimi-k2", true], - ["llama-4", true], - ]) - expect(groq.models[1].api).toBe("@ai-sdk/groq") - }) - - test("an unroutable model sinks below a runnable one rather than disappearing", () => { - const rows = providerRows( - [integration("opencode", { name: "OpenCode Zen" })], - [ - { id: "gemini-x", providerID: "opencode", api: { type: "aisdk", package: "@ai-sdk/google" } }, - { id: "nemotron", providerID: "opencode", api: compatible }, - ], - ) - const zen = rows[0] - expect(usableCount(zen)).toBe(1) - expect(zen.models.map((model) => [model.id, model.runnable])).toEqual([ - ["nemotron", true], - ["gemini-x", false], + test("groups models by provider and builds the provider/model value a node stores", () => { + const rows = providerRows(models, providers) + const anthropic = rows.find((row) => row.id === "anthropic") + expect(anthropic?.name).toBe("Anthropic") + expect(anthropic?.models.map((model) => model.value)).toEqual([ + "anthropic/claude-haiku-5", + "anthropic/claude-opus-5", ]) + expect(anthropic?.models[1]).toMatchObject({ + id: "claude-opus-5", + name: "Claude Opus 5", + providerID: "anthropic", + providerName: "Anthropic", + reasoning: true, + contextWindow: 200_000, + }) }) - test("a provider whose every model is unroutable ranks below one that works", () => { - const rows = providerRows( - [integration("opencode", { name: "OpenCode Zen" }), integration("nvidia", { name: "Nvidia" })], - [ - { id: "gemini-x", providerID: "opencode", api: { type: "aisdk", package: "@ai-sdk/google" } }, - { id: "nemotron", providerID: "nvidia", api: compatible }, - ], - ) - expect(rows.map((row) => row.id)).toEqual(["nvidia", "opencode"]) - const zen = rows[1] - expect(isActive(zen)).toBe(true) - expect(usableCount(zen)).toBe(0) - }) - - test("a stored key that yields no models still reads as connected, not as ready", () => { - const rows = providerRows(catalog, models) - const openrouter = rows.find((row) => row.id === "openrouter")! - expect(isConnected(openrouter)).toBe(true) - expect(isActive(openrouter)).toBe(false) - }) - - test("a provider with neither a key nor models is offered but inert", () => { - const rows = providerRows(catalog, models) - const anthropic = rows.find((row) => row.id === "anthropic")! - expect(isConnected(anthropic)).toBe(false) - expect(isActive(anthropic)).toBe(false) - expect(anthropic.keyable).toBe(true) - }) - - test("orders ready, then keyed-but-silent, then the rest", () => { - const rows = providerRows(catalog, models) - expect(rows.map((row) => row.id)).toEqual(["groq", "opencode", "openrouter", "anthropic"]) - }) - - test("a provider is locked until a key is stored or its env var is really set", () => { - const rows = providerRows(catalog, models) - // groq holds a stored credential; opencode serves free models to a browser - // that has never seen a key, and must not count as connected. - expect(rows.filter((row) => row.unlocked).map((row) => row.id).sort()).toEqual(["groq", "openrouter"]) - expect(unlockedRows(rows).map((row) => row.id).sort()).toEqual(["groq", "openrouter"]) - expect(readyRows(rows).map((row) => row.id)).toEqual(["groq"]) - }) - - test("an environment variable that is actually set unlocks the provider", () => { - const rows = providerRows(catalog, models, ["ANTHROPIC_API_KEY"]) - const anthropic = rows.find((row) => row.id === "anthropic")! - expect(anthropic.unlocked).toBe(true) - expect(anthropic.envSet).toEqual(["ANTHROPIC_API_KEY"]) - // Still no key of ours — the dialog must not offer to remove one. - expect(isConnected(anthropic)).toBe(false) - }) - - test("an environment variable the provider declares but the host lacks changes nothing", () => { - const rows = providerRows(catalog, models, ["SOMETHING_ELSE"]) - expect(rows.find((row) => row.id === "anthropic")!.unlocked).toBe(false) - expect(rows.find((row) => row.id === "opencode")!.unlocked).toBe(false) - }) - - test("drops catalog models the provider itself does not serve", () => { - // models.dev listed 90 zen models on 2026-08-13; zen served 61. The 29 - // ghosts answer a run with `401 Model X is not supported`. - const serves = new Map([["opencode", new Set(["nemotron"])]]) - const rows = providerRows( - [integration("opencode", { name: "OpenCode Zen", credentials: ["cred_zen"] })], - [ - { id: "nemotron", providerID: "opencode", api: compatible }, - { id: "kimi-k2", providerID: "opencode", api: compatible }, - ], - [], - serves, - ) - expect(rows[0].models.map((model) => model.id)).toEqual(["nemotron"]) - }) - - test("a provider with no entry in the served map is left exactly as the catalog reports it", () => { - const serves = new Map([["opencode", new Set(["nemotron"])]]) - const rows = providerRows(catalog, models, [], serves) - expect(rows.find((row) => row.id === "groq")!.models).toHaveLength(2) - }) - - test("keeps models whose provider the integration list omits", () => { - // Environment-variable providers appear in the model list; an integration - // list that lags behind must not delete them from the dropdown. - const rows = providerRows([], [{ id: "gpt-5", providerID: "openai", name: "GPT-5" }]) - expect(rows).toHaveLength(1) - expect(rows[0].id).toBe("openai") - expect(rows[0].keyable).toBe(false) - expect(isActive(rows[0])).toBe(true) - // Nothing here can be keyed, so hiding its models would hide them forever. - expect(rows[0].unlocked).toBe(true) + test("a model without a name shows its id, and a model id containing a slash stays whole", () => { + const openrouter = providerRows(models, providers).find((row) => row.id === "openrouter") + expect(openrouter?.models[0]).toMatchObject({ value: "openrouter/meta/llama-4", name: "meta/llama-4", reasoning: false }) }) -}) - -describe("free models", () => { - const option = (over: Partial> = {}) => ({ ...optionBase(), ...over }) - function optionBase() { - return { - value: "opencode/nemotron-3.5-lightning-free", - id: "nemotron-3.5-lightning-free", - name: "Nemotron 3.5 Lightning (free)", - providerID: "opencode", - providerName: "OpenCode Zen", - runnable: true, - api: "@ai-sdk/openai-compatible", - } - } - test("accepts an opencode runnable model whose id ends in -free", () => { - expect(isFreeModel(option())).toBe(true) + test("authenticated providers unlock; an explicit false locks", () => { + const rows = providerRows(models, providers) + expect(rows.find((row) => row.id === "anthropic")).toMatchObject({ authenticated: true, unlocked: true }) + expect(rows.find((row) => row.id === "openai")).toMatchObject({ authenticated: false, unlocked: false }) }) - test("rejects a free-suffixed model from another provider", () => { - expect(isFreeModel(option({ value: "groq/x-free", id: "x-free", providerID: "groq" }))).toBe(false) + test("a provider omp serves models for but does not list is unknown, therefore available", () => { + const row = providerRows(models, providers).find((entry) => entry.id === "local-llm") + expect(row).toMatchObject({ authenticated: undefined, unlocked: true, name: "local-llm" }) + expect(row?.models).toHaveLength(1) }) - test("rejects an unrunnable free model", () => { - expect(isFreeModel(option({ runnable: false }))).toBe(false) + test("without a provider list nothing is locked", () => { + const rows = providerRows(models) + expect(rows.every((row) => row.unlocked)).toBe(true) + expect(availableModels(rows)).toHaveLength(models.length) }) - test("rejects an opencode model without the suffix", () => { - expect(isFreeModel(option({ value: "opencode/nemotron", id: "nemotron" }))).toBe(false) + test("providers with no models are still rows, so a search can offer to connect them", () => { + const rows = providerRows(models, providers) + expect(rows.find((row) => row.id === "xai")).toMatchObject({ models: [], unlocked: true }) + expect(rows.find((row) => row.id === "zai")).toMatchObject({ models: [], unlocked: false }) }) - test("freeModels and suggestedFreeDefault pick the runnable free zen models", () => { - const rows = providerRows( - [integration("opencode", { name: "OpenCode Zen" })], - [ - { id: "grok-code-free", providerID: "opencode", name: "Grok Code (free)", api: compatible }, - { id: "kimi-k2", providerID: "opencode", name: "Kimi K2", api: compatible }, - { id: "dead-free", providerID: "opencode", name: "Dead (free)", api: groqSdk }, - ], - ) - expect(freeModels(rows).map((model) => model.value)).toEqual(["opencode/grok-code-free"]) - expect(suggestedFreeDefault(rows)).toBe("opencode/grok-code-free") - }) - - test("no free model served means no suggestion and no crash", () => { - const rows = providerRows(catalog, models) - expect(freeModels(rows)).toEqual([]) - expect(suggestedFreeDefault(rows)).toBeUndefined() + test("rows with something to pick come first, locked and empty ones last", () => { + const order = providerRows(models, providers).map((row) => row.id) + expect(order).toEqual(["anthropic", "local-llm", "openrouter", "xai", "openai", "zai"]) }) }) -describe("a fresh install: no stored key, no environment variable", () => { - // Zen ids as the server really serves them: two keyless free ones, one paid - // model on the same provider, and a free id whose api the runner cannot route. - const zen = [ - { id: "nemotron-3.5-lightning-free", providerID: "opencode", name: "Nemotron 3.5 Lightning (free)", api: compatible }, - { id: "hy3-free", providerID: "opencode", name: "HY3 (free)", api: compatible }, - { id: "claude-opus-5", providerID: "opencode", name: "Claude Opus 5", api: compatible }, - { id: "gemini-3-free", providerID: "opencode", name: "Gemini 3 (free)", api: { type: "aisdk", package: "@ai-sdk/google" } }, - ] - const fresh = (serves?: Map>) => - providerRows( - [integration("opencode", { name: "OpenCode Zen" }), integration("anthropic", { name: "Anthropic", env: ["ANTHROPIC_API_KEY"] })], - zen, - [], - serves, - ) - - test("offers the free zen models to a user who has connected nothing", () => { - expect(availableModels(fresh()).map((model) => model.value)).toEqual([ - "opencode/hy3-free", - "opencode/nemotron-3.5-lightning-free", - ]) - expect(searchModels(fresh(), "").map((model) => model.value)).toEqual([ - "opencode/hy3-free", - "opencode/nemotron-3.5-lightning-free", - ]) - }) - - test("a paid zen model still needs a key, so a fresh install cannot spend money", () => { - expect(availableModels(fresh()).some((model) => model.id === "claude-opus-5")).toBe(false) - expect(searchModels(fresh(), "claude")).toEqual([]) - const keyed = providerRows([integration("opencode", { name: "OpenCode Zen", credentials: ["cred_zen"] })], zen) - expect(availableModels(keyed).map((model) => model.id)).toContain("claude-opus-5") - }) - - test("a free id the runner cannot route is not offered — it would fail silently", () => { - // zen's gemini-* are `@ai-sdk/google`; a node dispatching one produces no - // assistant message at all, which reads as OpenFlow hanging. - const opencode = fresh().find((row) => row.id === "opencode")! - expect(opencode.models.find((model) => model.id === "gemini-3-free")!.free).toBe(false) - expect(availableModels(fresh()).some((model) => model.id === "gemini-3-free")).toBe(false) - }) - - test("the free flag rides on the model, so the picker can label it", () => { - const opencode = fresh().find((row) => row.id === "opencode")! - expect(opencode.models.filter((model) => model.free).map((model) => model.id)).toEqual([ - "hy3-free", - "nemotron-3.5-lightning-free", - ]) - expect(opencode.models.find((model) => model.id === "claude-opus-5")!.free).toBe(false) - }) - - test("free models do not make their provider read as connected", () => { - // The distinction the whole gate rests on: the server being generous is not - // the user having connected zen. A remove-key button here would remove - // nothing, and the paid models must stay locked. - const opencode = fresh().find((row) => row.id === "opencode")! - expect(opencode.unlocked).toBe(false) - expect(isConnected(opencode)).toBe(false) - expect(unlockedRows(fresh()).map((row) => row.id)).toEqual([]) - }) - - test("a locked provider with no free model still contributes nothing", () => { - // openrouter and groq keep the old behaviour exactly: no key, no env var, - // no models — the `-free` suffix means nothing outside zen. - const rows = providerRows( - [integration("openrouter", { name: "OpenRouter", env: ["OPENROUTER_API_KEY"] })], - [{ id: "grok-4-free", providerID: "openrouter", name: "Grok 4 free", api: compatible }], - ) - expect(availableModels(rows)).toEqual([]) - expect(searchModels(rows, "")).toEqual([]) - }) - - test("an unreachable zen list leaves the free models available rather than wiping them", () => { - // `serves` undefined is UNKNOWN, not empty: an offline user keeps every zen - // model, including the only ones they can run without a key. - expect(availableModels(fresh(undefined)).map((model) => model.value)).toEqual([ - "opencode/hy3-free", - "opencode/nemotron-3.5-lightning-free", - ]) - }) - - test("a free id zen no longer serves is dropped like any other ghost", () => { - const serves = new Map([["opencode", new Set(["nemotron-3.5-lightning-free"])]]) - expect(availableModels(fresh(serves)).map((model) => model.value)).toEqual([ - "opencode/nemotron-3.5-lightning-free", - ]) - }) - - test("a search that names a free model finds it on the locked provider", () => { - expect(searchModels(fresh(), "nemotron").map((model) => model.value)).toEqual([ - "opencode/nemotron-3.5-lightning-free", - ]) - // A provider-name hit still cannot drag the paid models along. - expect(searchModels(fresh(), "zen").map((model) => model.id)).toEqual(["hy3-free", "nemotron-3.5-lightning-free"]) - }) +describe("availability", () => { + const rows = providerRows(models, providers) - test("connecting a provider adds its models without disturbing the free ones", () => { - const rows = providerRows( - [integration("opencode", { name: "OpenCode Zen" }), integration("anthropic", { name: "Anthropic", env: ["ANTHROPIC_API_KEY"] })], - [...zen, { id: "claude-sonnet-5", providerID: "anthropic", name: "Claude Sonnet 5", api: { type: "aisdk", package: "@ai-sdk/anthropic" } }], - ["ANTHROPIC_API_KEY"], - ) - expect(availableModels(rows).map((model) => model.value)).toEqual([ - "anthropic/claude-sonnet-5", - "opencode/hy3-free", - "opencode/nemotron-3.5-lightning-free", + test("availableModels is every model of an unlocked provider", () => { + expect(availableModels(rows).map((model) => model.value).sort()).toEqual([ + "anthropic/claude-haiku-5", + "anthropic/claude-opus-5", + "local-llm/tiny", + "openrouter/meta/llama-4", ]) }) -}) -describe("splitProviders", () => { - test("puts opencode's featured six first, in their order, then the rest by name", () => { - const rows = providerRows( - [ - integration("groq", { name: "Groq" }), - integration("openai", { name: "OpenAI" }), - integration("anthropic", { name: "Anthropic" }), - integration("cerebras", { name: "Cerebras" }), - ], - [], - ) - const { popular, other } = splitProviders(rows) - expect(popular.map((row) => row.id)).toEqual(["anthropic", "openai"]) - expect(other.map((row) => row.id)).toEqual(["cerebras", "groq"]) + test("unlockedRows keeps empty-but-authenticated providers, readyRows drops them", () => { + expect(unlockedRows(rows).map((row) => row.id)).toContain("xai") + expect(readyRows(rows).map((row) => row.id)).toEqual(["anthropic", "local-llm", "openrouter"]) }) }) describe("searchModels", () => { - const rows = providerRows(catalog, models) - - test("a locked provider contributes nothing, so a fresh install has no models", () => { - const fresh = providerRows( - [integration("opencode", { name: "OpenCode Zen" })], - [{ id: "nemotron", providerID: "opencode", name: "Nemotron", api: compatible }], - ) - expect(searchModels(fresh, "")).toEqual([]) + const rows = providerRows(models, providers) + + test("an empty query lists every available model in row order", () => { + expect(searchModels(rows, "").map((model) => model.value)).toEqual([ + "anthropic/claude-haiku-5", + "anthropic/claude-opus-5", + "local-llm/tiny", + "openrouter/meta/llama-4", + ]) }) - test("empty query lists every model of every unlocked provider", () => { - expect(searchModels(rows, "").map((model) => model.value)).toEqual(["groq/kimi-k2", "groq/llama-4"]) + test("a locked provider contributes nothing, even to a query that names its model", () => { + expect(searchModels(rows, "gpt")).toEqual([]) + expect(searchModels(rows, "openai")).toEqual([]) }) - test("matches the provider/model value users actually type", () => { - expect(searchModels(rows, "groq/kimi").map((model) => model.value)).toEqual(["groq/kimi-k2"]) + test("matches the joined provider/model value", () => { + expect(searchModels(rows, "anthropic/claude-o").map((model) => model.id)).toEqual(["claude-opus-5"]) + expect(searchModels(rows, "meta/llama").map((model) => model.value)).toEqual(["openrouter/meta/llama-4"]) }) - test("a provider match keeps all of its models", () => { - expect(searchModels(rows, "Groq")).toHaveLength(2) + test("a provider name brings all of its models", () => { + expect(searchModels(rows, "Anthropic")).toHaveLength(2) }) - test("a display-name match finds a model whose id does not contain the query", () => { - expect(searchModels(rows, "llama").map((model) => model.value)).toEqual(["groq/llama-4"]) + test("matches the display name, case-insensitively", () => { + expect(searchModels(rows, "OPUS").map((model) => model.id)).toEqual(["claude-opus-5"]) }) - test("respects the limit so a 6000-model catalog cannot lock the dropdown", () => { + test("the limit holds", () => { expect(searchModels(rows, "", 2)).toHaveLength(2) }) }) describe("searchProviders", () => { - const rows = providerRows(catalog, models) + const rows = providerRows(models, providers) - test("matches on id, name and environment variable", () => { - expect(searchProviders(rows, "anthropic").map((row) => row.id)).toEqual(["anthropic"]) - expect(searchProviders(rows, "zen").map((row) => row.id)).toEqual(["opencode"]) - expect(searchProviders(rows, "GROQ_API_KEY").map((row) => row.id)).toEqual(["groq"]) + test("an empty query keeps every row", () => { + expect(searchProviders(rows, " ")).toHaveLength(rows.length) }) - test("empty query returns everything", () => { - expect(searchProviders(rows, " ")).toHaveLength(4) + test("matches id or display name", () => { + expect(searchProviders(rows, "z.ai").map((row) => row.id)).toEqual(["zai"]) + expect(searchProviders(rows, "openrouter").map((row) => row.id)).toEqual(["openrouter"]) }) }) describe("groupMatches", () => { - test("regroups a flat match list under one heading per provider run", () => { - // opencode's free models join the list only once its key is stored. - const rows = providerRows( - [...catalog.filter((item) => item.id !== "opencode"), integration("opencode", { credentials: ["cred_zen"] })], - models, - ) + test("regroups a flat match list under provider headings, in order", () => { + const rows = providerRows(models, providers) const groups = groupMatches(searchModels(rows, "")) - expect(groups.map((group) => [group.providerID, group.models.length])).toEqual([ - ["groq", 2], - ["opencode", 1], + expect(groups.map((group) => [group.providerID, group.providerName, group.models.length])).toEqual([ + ["anthropic", "Anthropic", 2], + ["local-llm", "local-llm", 1], + ["openrouter", "OpenRouter", 1], ]) }) }) + +describe("from the host", () => { + const server = Bun.serve({ + port: 0, + fetch(request) { + const path = new URL(request.url).pathname + if (path === `${ENGINE_BASE}/models`) return Response.json(models) + if (path === OMP_ROUTES.providers) return Response.json(providers) + return Response.json({ error: "no such route" }, { status: 404 }) + }, + }) + + beforeAll(() => client.setOrigin(server.url.origin)) + afterAll(async () => { + client.setOrigin("") + await server.stop(true) + }) + + test("the catalog and the provider list a browser fetches join into the picker's rows", async () => { + const fetched = (await (await fetch(`${server.url.origin}${OMP_ROUTES.providers}`)).json()) as OmpProviderRow[] + const rows = providerRows(await client.models(), fetched) + expect(availableModels(rows).map((model) => model.value)).toContain("anthropic/claude-opus-5") + expect(availableModels(rows).map((model) => model.value)).not.toContain("openai/gpt-5") + }) +}) diff --git a/packages/flow/src/server/providers.ts b/packages/flow/src/server/providers.ts index 20b709af43..af6e0c66f8 100644 --- a/packages/flow/src/server/providers.ts +++ b/packages/flow/src/server/providers.ts @@ -1,18 +1,20 @@ -import type { Integration } from "./client" +import type { ModelInfo } from "../../shared/engine" +import type { OmpProviderRow } from "../../shared/omp" /** - * How the model dropdown and the provider panel see the server's catalog. + * How the model dropdown sees Omp's catalog. * - * Two lists arrive from the server and neither answers the question on its - * own. `GET /api/integration` lists every provider models.dev knows — - * ~184 of them — plus the credentials stored for each. `GET /api/model` lists - * only the models the server would actually accept right now: a provider - * appears there once it has a stored key OR its environment variable is set. + * Two lists arrive from the host and neither answers the question on its own. + * `GET /flow/api/engine/models` lists the models omp can dispatch; `GET + * /flow/api/omp/providers` lists every provider omp can log in to, with + * whether credentials are present. Joining them says which models a user can + * choose *and* which providers are worth offering to connect. * - * Joining them is what makes the difference visible: "has models" means - * usable, "has a connection" means OpenFlow stored the key, and a provider - * with models but no connection is authenticated by environment variable and - * must not be offered a remove button — there is nothing here to remove. + * Authentication is three-valued on purpose. `authenticated: undefined` means + * "nobody said" — the provider list failed to load, or omp serves models for a + * provider it does not list (a custom `models.yml` provider). Hiding those + * models would be a lie, since omp itself dispatches them, so unknown reads as + * available. Only an explicit `false` locks a provider. */ export type ModelOption = { /** The `providerID/modelID` string a node stores. */ @@ -21,340 +23,99 @@ export type ModelOption = { name: string providerID: string providerName: string - /** False when the session runner has no route for this model's API. */ - runnable: boolean - /** The API shape, shown when it is the reason a model cannot run. */ - api: string - /** - * Zen's free tier: runs with no key, no account and no bill. - * - * The only flag here that grants access rather than describing it — a free - * model is offered on a fresh install while every other model on the same - * locked provider is not. The quota is shared across everyone using it, so a - * free model can answer `429 FreeUsageLimit` while the paid ones beside it - * are fine. See [isFreeModel]. - */ - free: boolean + reasoning: boolean + contextWindow?: number } -/** - * Whether the server can actually execute this model, as opposed to merely - * listing it. - * - * The v2 session runner routes exactly three API shapes — OpenAI responses, - * Anthropic messages, and OpenAI-compatible chat with a base URL - * (`core/src/session/runner/model.ts`, `fromCatalogModel`). Everything else, - * `@ai-sdk/groq` and `@openrouter/ai-sdk-provider` among them, fails the - * moment a node dispatches: - * - * SessionRunnerModel.UnsupportedApiError: Unsupported API for - * groq/llama-3.3-70b-versatile: aisdk:@ai-sdk/groq - * - * The catalog cheerfully lists all of them, which is what makes a keyed - * provider look connected and still fail. Mirroring the check here is a - * duplicate of server logic and will drift when the runner learns a new - * package — the cost of being wrong is a model marked unusable that works, so - * these stay selectable rather than hidden. - * - * A fourth shape reaches the runner by provider id rather than by package: the - * OpenAI-compatible profile table in - * `llm/src/providers/openai-compatible-profile.ts`, which the runner falls back - * to for a provider whose package it has no protocol for. That is what makes - * every `openrouter` and `groq` model dispatchable despite their packages. See - * [COMPATIBLE_PROVIDERS] for what that costs when the server is older. - */ -export function runnable(api?: { type?: string; package?: string; url?: string }, providerID?: string) { - if (!api || api.type !== "aisdk") return false - if (api.package === "@ai-sdk/openai" || api.package === "@ai-sdk/anthropic") return true - if (api.package === "@ai-sdk/openai-compatible" && api.url) return true - return providerID !== undefined && COMPATIBLE_PROVIDERS.has(providerID) -} - -/** - * Providers the runner can route by id alone, copied from upstream's - * `openai-compatible-profile.ts`. Each one speaks OpenAI chat and the profile - * supplies the base URL, so a model of theirs runs even when its package has no - * protocol (`@openrouter/ai-sdk-provider`) and its catalog entry carries no URL - * at all (`@ai-sdk/groq`). - * - * This is the one place OpenFlow is ahead of the engine it ships against: the - * fallback landed upstream after this fork's vendored core. Against an engine - * without it these models read as runnable and still fail to dispatch, unless - * the provider is repackaged in the global config (see FLOW.md). Keep the list - * in sync with upstream rather than growing a local one. - */ -const COMPATIBLE_PROVIDERS = new Set([ - "baseten", - "cerebras", - "deepinfra", - "deepseek", - "fireworks", - "groq", - "openrouter", - "togetherai", - "xai", -]) - export type ProviderRow = { id: string name: string - /** Environment variables that authenticate this provider without a stored key. */ - env: string[] - /** Environment variables from `env` that are actually set on the host. */ - envSet: string[] - /** Credentials OpenFlow (or the server) stored for this provider. */ - connections: Array<{ id: string; label?: string }> models: ModelOption[] - /** Accepts a key through `connect/key` — every models.dev provider does. */ - keyable: boolean - /** A key reached this provider: stored here, or an env var that is really set. */ + /** Omp's verdict on credentials; `undefined` when it gave none. */ + authenticated?: boolean + /** The user can pick this provider's models: authenticated, or not known to be otherwise. */ unlocked: boolean } /** - * The order the connect dialog puts providers in, copied from opencode's own - * `featured` list in `dialog-connect-provider.tsx`. - */ -export const FEATURED = ["opencode", "anthropic", "openai", "google", "openrouter", "vercel"] - -export type RawModel = { - id: string - providerID: string - name?: string - api?: { type?: string; package?: string; url?: string } -} - -/** - * `env` is the set of environment variable names the *host* actually has set, - * from `GET /flow/api/env`. Without it every provider reads as locked, which is - * the safe direction: a fresh install shows providers, not models. - * - * `serves` is what a provider says it serves, keyed by provider id — today only - * `opencode`, from `GET /flow/api/zen-models`. A provider absent from the map - * is left exactly as the catalog reports it; a provider present in it keeps - * only the models on its own list. See [../../lib/zen.ts] for why the catalog - * cannot be trusted on this and why an unreachable list must be omitted rather - * than passed as an empty set. + * `providers` is optional: without it every provider is unknown and therefore + * unlocked, which is the direction that never hides a model omp would run. */ -export function providerRows( - integrations: Integration[], - models: RawModel[], - env: Iterable = [], - serves?: Map>, -): ProviderRow[] { - const present = env instanceof Set ? env : new Set(env) - const names = new Map(integrations.map((item) => [item.id, item.name])) +export function providerRows(models: ModelInfo[], providers: OmpProviderRow[] = []): ProviderRow[] { + const listed = new Map(providers.map((provider) => [provider.id, provider])) const byProvider = new Map() for (const model of models) { - const listed = serves?.get(model.providerID) - if (listed && !listed.has(model.id)) continue const list = byProvider.get(model.providerID) ?? [] - const routed = runnable(model.api, model.providerID) list.push({ value: `${model.providerID}/${model.id}`, id: model.id, name: model.name || model.id, providerID: model.providerID, - providerName: names.get(model.providerID) ?? model.providerID, - runnable: routed, - api: model.api?.type === "aisdk" ? (model.api.package ?? "aisdk") : (model.api?.type ?? "unknown"), - free: isFreeModel({ providerID: model.providerID, id: model.id, runnable: routed }), + providerName: listed.get(model.providerID)?.name ?? model.providerID, + reasoning: model.reasoning === true, + contextWindow: model.contextWindow, }) byProvider.set(model.providerID, list) } - const rows: ProviderRow[] = integrations.map((item) => { - const env = item.methods.flatMap((method) => (method.type === "env" ? (method.names ?? []) : [])) - const envSet = env.filter((name) => present.has(name)) - const connections = (item.connections ?? []) - .filter((connection) => connection.type === "credential") - .map((connection) => ({ id: connection.id, label: connection.label })) - return { - id: item.id, - name: item.name || item.id, - env, - envSet, - connections, - models: sortModels(byProvider.get(item.id) ?? []), - keyable: item.methods.some((method) => method.type === "key"), - unlocked: connections.length > 0 || envSet.length > 0, - } - }) - - // A provider the catalog serves models for but the integration list omits - // still has to be selectable — otherwise its models would vanish from the - // dropdown while sessions using them keep working. - for (const [providerID, list] of byProvider) { - if (rows.some((row) => row.id === providerID)) continue + const ids = new Set([...listed.keys(), ...byProvider.keys()]) + const rows: ProviderRow[] = [] + for (const id of ids) { + const provider = listed.get(id) rows.push({ - id: providerID, - name: names.get(providerID) ?? providerID, - env: [], - envSet: [], - connections: [], - models: sortModels(list), - keyable: false, - // Nothing here can be keyed and nothing else explains the models, so the - // server authenticated it some other way — hiding them would be a lie. - unlocked: true, + id, + name: provider?.name ?? id, + models: (byProvider.get(id) ?? []).sort((a, b) => a.name.localeCompare(b.name)), + authenticated: provider?.authenticated, + unlocked: provider ? provider.authenticated : true, }) } - return rows.sort(compareRows) } -/** - * Zen's free tier marks its keyless models with a `-free` suffix on the id - * (e.g. `nemotron-3.5-lightning-free`). The suffix is the only stable marker — - * the exact ids change — so it lives here, in one place, rather than as a - * hardcoded model id anywhere. - */ -const FREE_SUFFIX = "-free" - -/** - * A free, keyless model: served by the `opencode` (zen) provider, actually - * runnable, and carrying zen's free-tier suffix. This is what lets a fresh - * install "just try it" with no key and no signup. - * - * All three conditions carry weight. The provider check keeps a `-free` id from - * some other provider — where the suffix means nothing — from unlocking itself. - * The suffix check keeps zen's *paid* models (claude-*, gpt-5*, glm-*, kimi-*, - * which sit on the same row and bill a connected account) locked. The runnable - * check drops the free ids with no route, zen's `gemini-*-free` among them: - * they use `@ai-sdk/google`, produce no assistant message at all, and reading - * as free would make silence look like the free tier's fault. - * - * Beyond that, a listed model is still only a claim — `deepseek-v4-flash-free` - * answered HTTP 400 and `mimo-v2.5-free` 429 on 2026-08-23 while their - * neighbours ran. Which ids are healthy changes by the hour, so nothing here - * pins one: only a real prompt proves a model, which is what the per-node test - * button is for. - * - * Takes the fields rather than a whole [ModelOption] because it is what - * computes that type's `free`. - */ -export function isFreeModel(option: { providerID: string; id: string; runnable: boolean }): boolean { - return option.providerID === "opencode" && option.runnable && option.id.endsWith(FREE_SUFFIX) -} - -/** Every runnable free zen model across the catalog, provider order preserved. */ -export function freeModels(rows: ProviderRow[]): ModelOption[] { - return rows.flatMap((row) => row.models).filter((model) => model.free) +/** Providers the user can use first, then those with models to offer, then the rest by name. */ +function compareRows(a: ProviderRow, b: ProviderRow) { + const rank = (row: ProviderRow) => (row.unlocked ? (row.models.length ? 0 : 1) : row.models.length ? 2 : 3) + const difference = rank(a) - rank(b) + if (difference !== 0) return difference + return a.name.localeCompare(b.name) } /** * Every model a node could dispatch right now — the set pre-run validation * checks a chosen model against. - * - * Not "the models of an unlocked provider". Zen's free tier needs no key, so a - * fresh install can run those and nothing else, while the paid zen models on - * that same locked row stay out: dispatching one would bill an account the user - * never connected. Free-ness is per model, so the gate has to be too. */ export function availableModels(rows: ProviderRow[]): ModelOption[] { - return rows.flatMap((row) => row.models.filter((model) => model.runnable && (row.unlocked || model.free))) -} - -/** `opencode/` of the first runnable free model, or undefined when none is served. */ -export function suggestedFreeDefault(rows: ProviderRow[]): string | undefined { - return freeModels(rows)[0]?.value + return rows.flatMap((row) => (row.unlocked ? row.models : [])) } -/** The server offers models for this provider — it may still not run them. */ -export function isActive(row: ProviderRow) { - return row.models.length > 0 -} - -/** Models this build can actually dispatch. The number that matters. */ -export function usableCount(row: ProviderRow) { - return row.models.filter((model) => model.runnable).length -} - -/** Keyed by OpenFlow rather than by an environment variable. */ -export function isConnected(row: ProviderRow) { - return row.connections.length > 0 -} - -/** - * Providers whose models the picker is allowed to show. - * - * A model list is not the same as an entitlement. `opencode serve` hands out - * the free zen models to a browser that has never seen an API key, so a fresh - * install would otherwise open onto a dropdown full of models the user never - * chose a provider for. The rule here is the one the user goes through: pick a - * provider, give it a key — or have its environment variable already set — - * and only then do its models exist. - * - * This stays whole-row and strict, so it still answers "which providers did the - * user connect". The free zen tier is an exception granted per model, not per - * provider — [availableModels] and [searchModels] apply it; a row that owes its - * only runnable models to that exception is still not connected. - */ +/** Providers whose models the picker is allowed to show. */ export function unlockedRows(rows: ProviderRow[]) { return rows.filter((row) => row.unlocked) } -/** Providers with at least one model this build can dispatch. */ +/** Unlocked providers that have at least one model to pick. */ export function readyRows(rows: ProviderRow[]) { - return unlockedRows(rows).filter((row) => usableCount(row) > 0) -} - -/** - * The connect dialog's two sections: the six providers opencode features, then - * everything else alphabetically. - */ -export function splitProviders(rows: ProviderRow[]) { - const popular = rows - .filter((row) => FEATURED.includes(row.id)) - .sort((a, b) => FEATURED.indexOf(a.id) - FEATURED.indexOf(b.id)) - const other = rows.filter((row) => !FEATURED.includes(row.id)).sort((a, b) => a.name.localeCompare(b.name)) - return { popular, other } -} - -/** - * Providers that can run something first, then providers that list models - * none of which this build can dispatch (openrouter's 349, groq's 15 — keyed, - * listed, and dead), then a stored key with no models at all, then the rest. - */ -function compareRows(a: ProviderRow, b: ProviderRow) { - const rank = (row: ProviderRow) => - usableCount(row) ? 0 : isActive(row) ? 1 : isConnected(row) ? 2 : 3 - const difference = rank(a) - rank(b) - if (difference !== 0) return difference - return a.name.localeCompare(b.name) -} - -/** Runnable models first, so the top of a provider's list is always usable. */ -function sortModels(models: ModelOption[]) { - return [...models].sort((a, b) => { - if (a.runnable !== b.runnable) return a.runnable ? -1 : 1 - return a.name.localeCompare(b.name) - }) + return unlockedRows(rows).filter((row) => row.models.length > 0) } /** * Substring match over provider and model, in the order the dropdown renders. * - * A locked provider contributes only its free models — see [unlockedRows] for - * why everything else of its is hidden, and [isFreeModel] for why the free zen - * tier is exempt. `openai/gpt` has to match too, so the joined `provider/model` - * value is part of the haystack rather than the two halves being matched - * separately. + * A locked provider contributes nothing: choosing one of its models would fail + * at the first prompt. `openai/gpt` has to match too, so the joined + * `provider/model` value is part of the haystack rather than the two halves + * being matched separately. */ export function searchModels(rows: ProviderRow[], query: string, limit = 200): ModelOption[] { const needle = query.trim().toLowerCase() const out: ModelOption[] = [] for (const row of rows) { - if (!row.models.length) continue + if (!row.unlocked) continue const providerHit = !needle || row.id.toLowerCase().includes(needle) || row.name.toLowerCase().includes(needle) for (const model of row.models) { - if (!row.unlocked && !model.free) continue if (out.length >= limit) return out - if ( - providerHit || - model.value.toLowerCase().includes(needle) || - model.name.toLowerCase().includes(needle) - ) { + if (providerHit || model.value.toLowerCase().includes(needle) || model.name.toLowerCase().includes(needle)) { out.push(model) } } @@ -362,16 +123,11 @@ export function searchModels(rows: ProviderRow[], query: string, limit = 200): M return out } -/** Filters the provider panel: matches id, display name, or environment variable. */ +/** Filters providers by id or display name. */ export function searchProviders(rows: ProviderRow[], query: string): ProviderRow[] { const needle = query.trim().toLowerCase() if (!needle) return rows - return rows.filter( - (row) => - row.id.toLowerCase().includes(needle) || - row.name.toLowerCase().includes(needle) || - row.env.some((name) => name.toLowerCase().includes(needle)), - ) + return rows.filter((row) => row.id.toLowerCase().includes(needle) || row.name.toLowerCase().includes(needle)) } /** Groups a flat match list back into the provider headings the list renders. */ diff --git a/packages/flow/src/server/sessions.test.ts b/packages/flow/src/server/sessions.test.ts index d7732189e4..cb60a55102 100644 --- a/packages/flow/src/server/sessions.test.ts +++ b/packages/flow/src/server/sessions.test.ts @@ -1,76 +1,5 @@ import { describe, expect, test } from "bun:test" -import { formatAge, matches, sessionLabel, sessionRows, transcriptTurns } from "./sessions" - -describe("sessionRows", () => { - test("projects a session as the server returns it", () => { - const [row] = sessionRows([ - { - id: "ses_01", - title: "refactor auth", - agent: "coder", - model: { providerID: "anthropic", id: "claude-opus-5" }, - projectID: "prj_01", - cost: 0, - time: { created: 1000, updated: 2000 }, - }, - ]) - expect(row).toEqual({ - id: "ses_01", - title: "refactor auth", - agent: "coder", - model: "anthropic/claude-opus-5", - parent: undefined, - created: 1000, - updated: 2000, - }) - }) - - test("keeps an untitled session, since it is usually the newest one", () => { - const [row] = sessionRows([{ id: "ses_02", title: " ", time: { created: 5 } }]) - expect(row.title).toBe("untitled session") - // no `updated` on the wire falls back to `created` rather than to 1970, - // which would sort the newest session to the bottom - expect(row.updated).toBe(5) - }) - - test("marks a child session so a subagent is not read as a node", () => { - const [row] = sessionRows([{ id: "ses_03", title: "grep", parentID: "ses_01", time: { created: 1 } }]) - expect(row.parent).toBe("ses_01") - }) -}) - -describe("transcriptTurns", () => { - // The shape the server actually returns: a user message holds a top-level - // `text` and no `content`, an assistant message holds `content` parts. - test("keeps user and assistant text in order, across both message shapes", () => { - expect( - transcriptTurns([ - { type: "user", text: "fix the test" }, - { type: "assistant", content: [{ type: "text", text: "done" }] }, - ]), - ).toEqual([ - { role: "user", text: "fix the test" }, - { role: "assistant", text: "done" }, - ]) - }) - - test("a user message is not dropped for having no content array", () => { - expect(transcriptTurns([{ type: "user", text: " hello " }])).toEqual([{ role: "user", text: "hello" }]) - }) - - test("drops tool parts and the turns that are only tool calls", () => { - expect( - transcriptTurns([ - { type: "assistant", content: [{ type: "tool", name: "grep" }] }, - { type: "assistant", content: [{ type: "tool", name: "read" }, { type: "text", text: "found it" }] }, - ]), - ).toEqual([{ role: "assistant", text: "found it" }]) - }) - - test("ignores message kinds that are not turns", () => { - expect(transcriptTurns([{ type: "summary", content: [{ type: "text", text: "compacted" }] }])).toEqual([]) - }) -}) +import { formatAge, matches, sessionLabel } from "./sessions" describe("sessionLabel", () => { const base = { id: "ses_01", created: 0, updated: 0 } @@ -79,19 +8,12 @@ describe("sessionLabel", () => { expect(sessionLabel({ ...base, title: "refactor auth", agent: "coder" })).toBe("refactor auth") }) - test("falls back to the agent, because every node session is auto-titled", () => { - expect(sessionLabel({ ...base, title: "New session - 2026-08-26T00:26:27.570Z", agent: "plan-and-code-coder-nmt6c" })) - .toBe("plan-and-code-coder-nmt6c") - }) - - test("keeps the auto-title when there is no agent to name instead", () => { - expect(sessionLabel({ ...base, title: "New session - 2026-08-26T00:26:27.570Z" })).toBe( - "New session - 2026-08-26T00:26:27.570Z", - ) + test("a blank title falls back to the card's agent key", () => { + expect(sessionLabel({ ...base, title: " ", agent: "plan-and-code-coder-nmt6c" })).toBe("plan-and-code-coder-nmt6c") }) - test("a title that merely starts similarly is still a real title", () => { - expect(sessionLabel({ ...base, title: "New sessions dashboard", agent: "coder" })).toBe("New sessions dashboard") + test("keeps a placeholder when there is neither, since a blank row is unclickable", () => { + expect(sessionLabel({ ...base, title: "" })).toBe("untitled session") }) }) diff --git a/packages/flow/src/server/sessions.ts b/packages/flow/src/server/sessions.ts index f6b6c2a018..050fe310d8 100644 --- a/packages/flow/src/server/sessions.ts +++ b/packages/flow/src/server/sessions.ts @@ -1,106 +1,23 @@ -/** - * One row of the sessions sidebar. - * - * This is a projection of `Session.Info` as `GET /api/session` returns it — - * the drizzle-backed `session` table in `opencode.db`, the same rows the CLI - * and TUI list. OpenFlow keeps no session store of its own: a node's session is - * created through the server ([client.createSession]) and therefore already - * lives there, so a second copy could only ever drift. - */ -export type SessionRow = { - id: string - title: string - agent?: string - model?: string - /** A child session — a subagent the card spawned — rather than a node's own. */ - parent?: string - created: number - updated: number -} - -/* - * Deliberately not projected: `cost`. This vendored server build reports - * `cost: 0` on every session (see `server/usage.ts`), so a per-session price in - * this list would read as "free" for work that was not. Spend has one home, the - * spend panel, where it is computed from the models.dev tiers. - */ - -export type Turn = { - role: "user" | "assistant" - text: string -} - -/** - * Narrows `Session.Info` to what the sidebar draws. - * - * The wire shape is untyped here on purpose: the generated client types - * `session.list` through its own schema, and mirroring `Session.Info` in flow - * would be a second definition to keep in step with upstream for no gain. What - * this file owns is the projection, and that is what the tests pin. - */ -export function sessionRows(list: any[]): SessionRow[] { - return list.map((entry) => ({ - id: entry.id, - // An untitled session is still worth listing — it is usually the one that - // just started, which is the one a user is looking for. - title: (entry.title ?? "").trim() || "untitled session", - agent: entry.agent ?? undefined, - model: entry.model ? `${entry.model.providerID}/${entry.model.id}` : undefined, - parent: entry.parentID ?? undefined, - created: Number(entry.time?.created ?? 0), - updated: Number(entry.time?.updated ?? entry.time?.created ?? 0), - })) -} +import type { SessionRow } from "../../shared/engine" -/** - * Flattens a message page into readable turns. - * - * The two message kinds are not shaped alike, and assuming they were is what - * made the first draft of this render assistant replies to nothing: a **user** - * message carries its prompt in a top-level `text` and has no `content` at all, - * while an **assistant** message carries an array of parts. Both paths are - * therefore read, and both are covered by tests. - * - * Tool parts are dropped rather than rendered: this is a "what was this session - * about" view, and the blow-by-blow already has a home in the activity drawer. - * A turn left with no text (a pure tool step) contributes nothing and goes. - */ -export function transcriptTurns(messages: any[]): Turn[] { - return messages - .filter((message) => message.type === "user" || message.type === "assistant") - .map((message) => ({ - role: message.type as Turn["role"], - text: (typeof message.text === "string" - ? message.text - : (message.content ?? []) - .filter((part: any) => part.type === "text" && typeof part.text === "string") - .map((part: any) => part.text) - .join("\n") - ).trim(), - })) - .filter((turn) => turn.text.length > 0) -} +export type { SessionRow, Turn } from "../../shared/engine" -/** - * What the server names a session it was given no title for. - * - * Nothing in OpenFlow sets a title — a node prompts its session and never - * renames it — so this is what every node session is called, and showing it as - * the card's headline would give a column of identical rows stamped with dates. +/* + * Deliberately not on the row: cost. Omp reports a per-step `usage.cost`, but + * the engine prices from the catalog (`server/usage.ts`), and a second number + * per session would disagree with the spend panel. Spend has one home. */ -const AUTO_TITLE = /^New session\b/ /** * The line that identifies a session in the list. * - * A real title wins when there is one (the CLI and TUI set them). Failing that - * the generated agent is what a user recognises — "plan-and-code-coder-nmt6c…" - * is the node that ran — and only when there is neither does the auto-title get - * used, because a dated row still beats a blank one. + * The host sets the title (the card's role and node, or whatever omp named the + * session); a blank one is still worth listing — it is usually the session that + * just started, which is the one a user is looking for — so the generated agent + * key names it, and only without both does the placeholder show. */ export function sessionLabel(row: SessionRow) { - if (!AUTO_TITLE.test(row.title)) return row.title - return row.agent ?? row.title + return row.title.trim() || row.agent || "untitled session" } /** @@ -118,12 +35,11 @@ export function formatAge(time: number, now: number) { } /** - * Client-side fallback filter. + * Client-side filter for the search box. * - * The search box sends `search` to the server, which matches it against the - * session table — that is the path that scales and the one that normally runs. - * This exists for the window between keystroke and response, so the list does - * not sit visibly stale while the request is in flight. + * Matches what the user can see — title, agent, model, id — because a card's + * session is titled by the host, not by the user, so a server-side title search + * would miss the fields that actually identify a node. */ export function matches(row: SessionRow, query: string) { const needle = query.trim().toLowerCase() diff --git a/packages/flow/src/server/stages.test.ts b/packages/flow/src/server/stages.test.ts new file mode 100644 index 0000000000..c6c7788f9e --- /dev/null +++ b/packages/flow/src/server/stages.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { MEMORY_ROUTES } from "../../shared/memory" +import type { MemoryReadRequest, MemoryReadResult, MemoryWriteRequest, MemoryWriteResult } from "../../shared/memory" +import { UPLIFT_ROUTE } from "../../shared/uplift" +import type { UpliftRequest, UpliftResult } from "../../shared/uplift" +import { createStages } from "./stages" + +const uplift: UpliftRequest = { runId: "r1", nodeId: "n1", role: "coder", original: "fix the bug" } +const read: MemoryReadRequest = { runId: "r1", nodeId: "n1", pipeline: "demo", query: "fix the bug" } +const write: MemoryWriteRequest = { runId: "r1", nodeId: "n1", pipeline: "demo", role: "coder", outcome: "done", output: "ok" } + +type Seen = { path: string; method: string; type: string | null; body: unknown } + +let servers: Bun.Server[] = [] + +/** A loopback host whose every answer is `answer(path)`. */ +function host(answer: (path: string) => Response) { + const seen: Seen[] = [] + const server = Bun.serve({ + port: 0, + async fetch(request) { + const path = new URL(request.url).pathname + seen.push({ path, method: request.method, type: request.headers.get("content-type"), body: await request.json().catch(() => undefined) }) + return answer(path) + }, + }) + servers.push(server) + return { seen, stages: createStages(`http://127.0.0.1:${server.port}`), server } +} + +afterEach(async () => { + for (const server of servers) await server.stop(true) + servers = [] +}) + +describe("uplift", () => { + test("posts the request and returns the host's answer untouched", async () => { + const answer = { status: "ok", original: "fix the bug", uplifted: "", elapsedMs: 12 } satisfies UpliftResult + const { seen, stages } = host(() => Response.json(answer)) + + expect(await stages.uplift(uplift)).toEqual(answer) + expect(seen).toEqual([{ path: UPLIFT_ROUTE, method: "POST", type: "application/json", body: uplift }]) + }) + + test("a skipped answer is passed through, not turned into a failure", async () => { + const { stages } = host(() => Response.json({ status: "skipped", original: "x", reason: "disabled", elapsedMs: 1 })) + expect((await stages.uplift(uplift)).status).toBe("skipped") + }) + + test("a 4xx answer becomes a failed result carrying the host's sentence and the fix", async () => { + const { stages } = host(() => Response.json({ error: "original is required", code: "bad-request" }, { status: 400 })) + const result = await stages.uplift(uplift) + + expect(result.status).toBe("failed") + expect(result.original).toBe("fix the bug") + expect(result.reason).toContain("original is required") + expect(result.reason).toContain("HTTP 400") + expect(result.reason).toContain("bun openflow.ts") + expect(result.elapsedMs).toBeGreaterThanOrEqual(0) + }) + + test("a 5xx answer with an HTML body still names the status", async () => { + const { stages } = host(() => new Response("bad gateway", { status: 502, statusText: "Bad Gateway" })) + const result = await stages.uplift(uplift) + + expect(result.status).toBe("failed") + expect(result.reason).toContain("HTTP 502") + }) + + test("a host that is down is a failed result, never a throw", async () => { + const { server } = host(() => Response.json({})) + const down = createStages(`http://127.0.0.1:${server.port}`) + await server.stop(true) + const result = await down.uplift(uplift) + + expect(result.status).toBe("failed") + expect(result.original).toBe("fix the bug") + expect(result.reason).toContain("did not answer") + expect(result.reason).toContain("bun openflow.ts") + }) + + test("a 200 answer that is not a stage result is a failed result", async () => { + for (const body of ["not json", "null", "[1]", "{}"]) { + const { stages } = host(() => new Response(body, { status: 200 })) + const result = await stages.uplift(uplift) + expect(result.status).toBe("failed") + expect(result.reason).toContain("not a stage result") + } + }) +}) + +describe("memory read", () => { + test("returns the host's answer", async () => { + const answer = { status: "ok", block: "", items: [], scopes: ["repo:a/b"], elapsedMs: 3 } satisfies MemoryReadResult + const { seen, stages } = host(() => Response.json(answer)) + + expect(await stages.memory.read(read)).toEqual(answer) + expect(seen[0]).toMatchObject({ path: MEMORY_ROUTES.read, method: "POST", body: read }) + }) + + test("an error answer is unavailable with an empty block", async () => { + const { stages } = host(() => Response.json({ error: "substrate is off" }, { status: 503 })) + const result = await stages.memory.read(read) + + expect(result).toMatchObject({ status: "unavailable", block: "", items: [], scopes: [] }) + expect(result.error).toContain("substrate is off") + expect(result.error).toContain("HTTP 503") + }) + + test("a host that is down is unavailable, never a throw", async () => { + const { server } = host(() => Response.json({})) + const down = createStages(`http://127.0.0.1:${server.port}`) + await server.stop(true) + const result = await down.memory.read(read) + + expect(result).toMatchObject({ status: "unavailable", block: "", items: [], scopes: [] }) + expect(result.error).toContain("did not answer") + }) + + test("malformed JSON is unavailable", async () => { + const { stages } = host(() => new Response("{oops", { status: 200 })) + expect((await stages.memory.read(read)).status).toBe("unavailable") + }) +}) + +describe("memory write", () => { + test("returns the host's answer", async () => { + const answer = { status: "stored", subject: "openflow/demo/r1/n1" } satisfies MemoryWriteResult + const { seen, stages } = host(() => Response.json(answer)) + + expect(await stages.memory.write(write)).toEqual(answer) + expect(seen[0]).toMatchObject({ path: MEMORY_ROUTES.write, method: "POST", body: write }) + }) + + test("an error answer is unavailable", async () => { + const { stages } = host(() => Response.json({ error: "denied by policy" }, { status: 403 })) + const result = await stages.memory.write(write) + + expect(result.status).toBe("unavailable") + expect(result.error).toContain("denied by policy") + }) + + test("a host that is down is unavailable, never a throw", async () => { + const { server } = host(() => Response.json({})) + const down = createStages(`http://127.0.0.1:${server.port}`) + await server.stop(true) + + expect(await down.memory.write(write)).toMatchObject({ status: "unavailable" }) + }) + + test("malformed JSON is unavailable", async () => { + const { stages } = host(() => new Response("", { status: 200 })) + expect((await stages.memory.write(write)).status).toBe("unavailable") + }) +}) diff --git a/packages/flow/src/server/stages.ts b/packages/flow/src/server/stages.ts new file mode 100644 index 0000000000..93bea86756 --- /dev/null +++ b/packages/flow/src/server/stages.ts @@ -0,0 +1,74 @@ +import { MEMORY_ROUTES } from "../../shared/memory" +import type { MemoryReadRequest, MemoryReadResult, MemoryWriteRequest, MemoryWriteResult } from "../../shared/memory" +import { UPLIFT_ROUTE } from "../../shared/uplift" +import type { UpliftRequest, UpliftResult } from "../../shared/uplift" +import { describe } from "./client" + +/** + * The engine's two stage deps (`EngineDeps.uplift` / `EngineDeps.memory`) as + * browser fetch clients of the host. Neither ever rejects: the engine already + * knows what a failed uplift (ask the user) and an unavailable memory (run + * without it, say so on the card) mean, so a dead host is reported in those + * terms rather than as an exception the engine would have to classify. + */ + +const HOST_FIX = "start it with `bun openflow.ts` (or check the Omp settings) and retry" + +/** Resolves the parsed body of a 2xx answer, or throws the sentence the caller should show. */ +async function post(origin: string, path: string, body: unknown): Promise { + let response: Response + try { + response = await fetch(`${origin}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) + } catch (error) { + throw new Error(`the OpenFlow host did not answer (${describe(error)}): ${HOST_FIX}`) + } + const text = await response.text().catch(() => "") + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + // Not JSON: an HTML error page from a proxy, or a truncated body. + } + if (!response.ok) throw new Error(`the OpenFlow host refused the request: ${describe(parsed, response)}; ${HOST_FIX}`) + // Every result type of these routes carries a `status`; a body without one is not the host's. + if (!parsed || typeof parsed !== "object" || !("status" in parsed) || typeof parsed.status !== "string") { + throw new Error(`the OpenFlow host answered with something that is not a stage result; ${HOST_FIX}`) + } + // Shape checked above; the route's own type is the host's contract. + return parsed as T +} + +/** `origin` is empty in the browser, where the page's own origin serves `/flow/api/*`; tests pass a loopback URL. */ +export function createStages(origin = "") { + async function uplift(request: UpliftRequest): Promise { + const started = Date.now() + try { + return await post(origin, UPLIFT_ROUTE, request) + } catch (error) { + return { status: "failed", reason: describe(error), original: request.original, elapsedMs: Date.now() - started } + } + } + + async function read(request: MemoryReadRequest): Promise { + const started = Date.now() + try { + return await post(origin, MEMORY_ROUTES.read, request) + } catch (error) { + return { status: "unavailable", block: "", items: [], scopes: [], error: describe(error), elapsedMs: Date.now() - started } + } + } + + async function write(request: MemoryWriteRequest): Promise { + try { + return await post(origin, MEMORY_ROUTES.write, request) + } catch (error) { + return { status: "unavailable", error: describe(error) } + } + } + + return { uplift, memory: { read, write } } +} diff --git a/packages/flow/src/server/store.test.ts b/packages/flow/src/server/store.test.ts index d394752703..00b3f8656e 100644 --- a/packages/flow/src/server/store.test.ts +++ b/packages/flow/src/server/store.test.ts @@ -1,230 +1,119 @@ -import { describe, expect, test } from "bun:test" -import { pipeline } from "../graph/test-support" -import type { Pipeline } from "../graph/types" -import { agentBlock, agentKey, mcpBlock, permissionBlock, TOOL_ACTIONS, TOOLS, toolMap } from "./store" - -function withAgents(graph: Pipeline, agents: Record>) { - return { - ...graph, - nodes: graph.nodes.map((node) => ({ ...node, agent: { ...node.agent, ...(agents[node.id] ?? {}) } })), - } -} - -describe("agentKey", () => { - test("joins the pipeline name, the role and the node id", () => { - const graph = pipeline("planner") - graph.nodes[0].id = "n1" - expect(agentKey(graph, graph.nodes[0])).toBe("test-planner-n1") - }) - - test("lowercases", () => { - const graph = { ...pipeline("planner"), name: "Feature-Build" } - graph.nodes[0].id = "N1" - expect(agentKey(graph, graph.nodes[0])).toBe("feature-build-planner-n1") - }) - - test("replaces characters an agent id cannot carry", () => { - const graph = { ...pipeline("code review!"), name: "my pipeline" } - graph.nodes[0].id = "n1" - expect(agentKey(graph, graph.nodes[0])).toBe("my-pipeline-code-review--n1") - }) - - test("keeps hyphens and underscores", () => { - const graph = { ...pipeline("worker_a"), name: "diamond-2" } - graph.nodes[0].id = "n_1" - expect(agentKey(graph, graph.nodes[0])).toBe("diamond-2-worker_a-n_1") - }) - - test("gives every node in a graph its own key", () => { - const graph = pipeline("a->b", "b->c") - const keys = graph.nodes.map((node) => agentKey(graph, node)) - expect(new Set(keys).size).toBe(3) - }) - - test("keeps two nodes with the same role apart", () => { - // The whole point of carrying the node id: without it both nodes land on - // one agent and the permissive one's tools win for both. - const twins = pipeline("coder", "coder2") - twins.nodes[1].role = "coder" - expect(agentKey(twins, twins.nodes[0])).not.toBe(agentKey(twins, twins.nodes[1])) - }) +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { store } from "./store" + +/** + * The store is the browser's client of `/flow/api`, so it fetches relative + * URLs. Outside a page those mean nothing: the fetch is pointed at a real + * loopback server instead, which records what it was asked. + */ +type Seen = { method: string; path: string; type: string | null; body: string } +const seen: Seen[] = [] +let respond: (path: string) => Response = () => Response.json({}) + +const server = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url) + seen.push({ + method: request.method, + path: url.pathname + url.search, + type: request.headers.get("content-type"), + body: await request.text(), + }) + return respond(url.pathname) + }, }) -describe("toolMap", () => { - test("passes through the names the server acts on", () => { - expect(toolMap({ read: true, bash: false })).toEqual({ read: true, bash: false }) - }) - - test("folds write and patch onto edit", () => { - expect(toolMap({ write: false })).toEqual({ edit: false }) - expect(toolMap({ patch: false })).toEqual({ edit: false }) - expect(toolMap({ "apply-patch": false })).toEqual({ edit: false }) - }) - - test("lets a deny win when aliases disagree", () => { - // Losing the restriction here would hand the agent more access than asked. - expect(toolMap({ write: false, edit: true })).toEqual({ edit: false }) - expect(toolMap({ edit: true, write: false })).toEqual({ edit: false }) - }) - - test("keeps a merged group enabled only when every alias agrees", () => { - expect(toolMap({ write: true, edit: true })).toEqual({ edit: true }) - }) - - test("drops names the server would turn into a dead rule", () => { - expect(toolMap({ task: false, nonsense: false, read: true })).toEqual({ read: true }) - }) +const realFetch = globalThis.fetch - test("keeps question, which gates whether a node can ask a person anything", () => { - expect(toolMap({ question: false })).toEqual({ question: false }) - }) - - test("ignores non-boolean values", () => { - expect(toolMap({ read: "yes" as any, bash: undefined as any })).toEqual({}) - }) - - test("handles a missing map", () => { - expect(toolMap(undefined)).toEqual({}) - }) - - test("every advertised tool maps to an action", () => { - for (const tool of TOOLS) expect(TOOL_ACTIONS[tool]).toBeTruthy() - }) +beforeAll(() => { + globalThis.fetch = ((input: Parameters[0], init?: Parameters[1]) => + realFetch(typeof input === "string" && input.startsWith("/") ? `${server.url.origin}${input}` : input, init)) as typeof fetch }) -describe("permissionBlock", () => { - test("spells out an allow and a deny for every toggle", () => { - expect(permissionBlock({ read: true, bash: false })).toEqual({ read: "allow", bash: "deny" }) - }) - - test("folds aliases before deciding", () => { - expect(permissionBlock({ write: true, edit: true })).toEqual({ edit: "allow" }) - expect(permissionBlock({ write: false, edit: true })).toEqual({ edit: "deny" }) - }) - - test("says nothing about actions the graph never mentions", () => { - // Silence leaves them on ask, which the engine answers at runtime; naming - // them here would widen access the graph never asked for. - const block = permissionBlock({ read: true }) - expect(block).not.toHaveProperty("external_directory") - expect(block).not.toHaveProperty("question") - expect(Object.keys(block)).toEqual(["read"]) - }) - - test("is empty when there are no toggles", () => { - expect(permissionBlock(undefined)).toEqual({}) - expect(permissionBlock({})).toEqual({}) - }) - - test("only ever emits allow or deny", () => { - const values = Object.values(permissionBlock({ read: true, grep: false, edit: true, bash: false })) - expect(new Set(values)).toEqual(new Set(["allow", "deny"])) - }) +afterAll(async () => { + globalThis.fetch = realFetch + await server.stop(true) }) -describe("agentBlock", () => { - const graph = withAgents(pipeline("planner->coder"), { - planner: { prompt: "You are the planner.", model: "opencode/some-model", tools: { read: true, edit: false } }, - coder: { prompt: "You are the coder.", tools: { write: true, bash: true } }, - }) - const block = agentBlock(graph) as Record - - test("emits one agent per node, keyed by agentKey", () => { - expect(Object.keys(block).sort()).toEqual(["test-coder-coder", "test-planner-planner"]) - }) - - test("uses the config's input vocabulary, not the reported form", () => { - // The server translates prompt -> system and permission -> permissions on - // load; emitting the translated names instead gets both fields ignored. - expect(block["test-planner-planner"].prompt).toBe("You are the planner.") - expect(block["test-planner-planner"].permission).toEqual({ read: "allow", edit: "deny" }) - expect(block["test-planner-planner"]).not.toHaveProperty("system") - expect(block["test-planner-planner"]).not.toHaveProperty("permissions") - }) - - test("writes permission, not the deprecated tools field", () => { - expect(block["test-planner-planner"]).not.toHaveProperty("tools") - }) +async function last(call: Promise) { + const result = await call + return { result, request: seen[seen.length - 1] } +} - test("runs nodes as primary agents", () => { - expect(block["test-planner-planner"].mode).toBe("primary") +describe("store requests", () => { + test("a GET carries no body and no content type", async () => { + respond = () => Response.json([{ name: "build", nodes: 2, updated: 1 }]) + const { result, request } = await last(store.pipelines()) + expect(result).toEqual([{ name: "build", nodes: 2, updated: 1 }]) + expect(request).toMatchObject({ method: "GET", path: "/flow/api/pipelines", type: null, body: "" }) }) - test("carries the model when the node pins one", () => { - expect(block["test-planner-planner"].model).toBe("opencode/some-model") + test("a write sends JSON with a content type", async () => { + respond = () => Response.json({ saved: true }) + const { request } = await last(store.saveRoles([{ id: "coder" }])) + expect(request).toMatchObject({ method: "PUT", path: "/flow/api/roles", type: "application/json" }) + expect(JSON.parse(request.body)).toEqual([{ id: "coder" }]) }) - test("omits the model when the node has none", () => { - expect(block["test-coder-coder"]).not.toHaveProperty("model") + test("names are encoded into the path", async () => { + respond = () => Response.json({}) + const { request } = await last(store.deletePipeline("my plan/v2")) + expect(request).toMatchObject({ method: "DELETE", path: "/flow/api/pipelines/my%20plan%2Fv2" }) }) - test("normalises tool aliases on the way out", () => { - expect(block["test-coder-coder"].permission).toEqual({ edit: "allow", bash: "allow" }) + test("a failure surfaces the host's own message", async () => { + respond = () => Response.json({ error: "no project open" }, { status: 409 }) + await expect(store.runs()).rejects.toThrow("no project open") }) - test("describes which node and pipeline an agent came from", () => { - expect(block["test-planner-planner"].description).toContain("planner") - expect(block["test-planner-planner"].description).toContain("test") + test("a failure without a readable body names the call and status", async () => { + respond = () => new Response("", { status: 502 }) + await expect(store.runs()).rejects.toThrow("GET /runs failed (502)") }) +}) - test("omits an empty prompt and an empty permission map", () => { - const bare = agentBlock(pipeline("solo")) as Record - expect(bare["test-solo-solo"]).not.toHaveProperty("prompt") - expect(bare["test-solo-solo"]).not.toHaveProperty("permission") - }) +describe("store routes of the Omp profile", () => { + test("mcp servers are saved and removed by name", async () => { + respond = () => Response.json({ name: "docs", path: "mcp.json" }) + const saved = await last(store.saveMcpServer({ name: "docs", type: "remote", enabled: true, url: "https://mcp.example/sse" })) + expect(saved.request).toMatchObject({ method: "PUT", path: "/flow/api/mcp/docs" }) + expect(JSON.parse(saved.request.body)).toMatchObject({ type: "remote", url: "https://mcp.example/sse" }) - test("is empty for a pipeline with no nodes", () => { - expect(agentBlock({ id: "x", name: "empty", nodes: [], edges: [] })).toEqual({}) + const removed = await last(store.deleteMcpServer("docs")) + expect(removed.request).toMatchObject({ method: "DELETE", path: "/flow/api/mcp/docs" }) }) - test("keeps two nodes with the same role on separate agents", () => { - // These used to collapse onto one agent, so the second node's tools decided - // the first node's permissions: a node restricted to read/grep inherited - // edit and bash, and quietly ran with write access to the real project. - const twins = withAgents(pipeline("coder", "coder2"), { - coder: { tools: { read: true, grep: true } }, - coder2: { tools: { edit: true, bash: true } }, - }) - twins.nodes[1].role = "coder" - - const result = agentBlock(twins) as Record - - expect(Object.keys(result).sort()).toEqual(["test-coder-coder", "test-coder-coder2"]) - expect(result["test-coder-coder"].permission).toEqual({ read: "allow", grep: "allow" }) - expect(result["test-coder-coder2"].permission).toEqual({ edit: "allow", bash: "allow" }) + test("a skill listing keeps the legacy flag the host puts on old entries", async () => { + respond = () => + Response.json([ + { name: "summarize", updated: 2 }, + { name: "old-skill", updated: 1, legacy: true }, + ]) + const { result } = await last(store.skills()) + expect(result.map((entry) => entry.legacy)).toEqual([undefined, true]) }) }) -describe("mcpBlock", () => { - test("says nothing for a node that never chose, so an old graph keeps working", () => { - expect(mcpBlock(undefined, ["context7", "figma"])).toEqual({}) +describe("store worktrees and checkpoints", () => { + test("opening worktrees posts the run and its cards", async () => { + respond = () => Response.json({ enabled: false, reason: "not a git repository" }) + const { result, request } = await last(store.openWorktrees("run-1", ["n1", "n2"])) + expect(result).toEqual({ enabled: false, reason: "not a git repository" }) + expect(request).toMatchObject({ method: "POST", path: "/flow/api/worktrees" }) + expect(JSON.parse(request.body)).toEqual({ run: "run-1", cards: ["n1", "n2"] }) }) - test("allows the chosen servers and denies the rest by wildcard", () => { - // MCP tools are named `_` and permission actions match by - // wildcard, so one rule per server covers every tool it exposes. - expect(mcpBlock(["context7"], ["context7", "figma"])).toEqual({ - "context7_*": "allow", - "figma_*": "deny", - }) + test("a checkpoint posts the run and round, and may answer null", async () => { + respond = () => Response.json(null) + const { result, request } = await last(store.checkpoint("run-1", 3)) + expect(result).toBeNull() + expect(JSON.parse(request.body)).toEqual({ run: "run-1", round: 3 }) }) - test("an empty allowlist is a real answer: none of them", () => { - expect(mcpBlock([], ["context7"])).toEqual({ "context7_*": "deny" }) - }) - - test("writes nothing when the project configures no servers", () => { - expect(mcpBlock(["ghost"], [])).toEqual({}) - }) -}) - -describe("agentBlock with mcp", () => { - test("folds the node's mcp allowlist into its permission block", () => { - const graph = withAgents(pipeline("a"), { a: { tools: { read: true }, mcp: ["context7"] } }) - - const block = agentBlock(graph, ["context7", "figma"]) as any - const agent = block[agentKey(graph, graph.nodes[0])] - - expect(agent.permission).toEqual({ read: "allow", "context7_*": "allow", "figma_*": "deny" }) + test("browse puts the folder in the query string, encoded", async () => { + respond = () => Response.json({ path: null, parent: null, entries: [] }) + const { request } = await last(store.browse("/tmp/a b")) + expect(request.path).toBe("/flow/api/browse?path=%2Ftmp%2Fa%20b") }) }) diff --git a/packages/flow/src/server/store.ts b/packages/flow/src/server/store.ts index a5b8220186..45a4d380fc 100644 --- a/packages/flow/src/server/store.ts +++ b/packages/flow/src/server/store.ts @@ -55,23 +55,17 @@ export type McpServer = { headers?: Record timeout?: number } -export type SkillEntry = { name: string; description?: string; updated: number } +export type SkillEntry = { name: string; description?: string; updated: number; legacy?: boolean } /** `name` is the frontmatter name shown to the agent; `folder` is the slug the API addresses it by. */ -export type SkillDoc = { name: string; folder: string; description?: string; content: string; path: string } -export type SkillSave = { name: string; path: string; registered?: boolean; backup?: string; error?: string } - -/** The `opencode serve` this host proxies, and whether it can be restarted here. */ -export type ServeStatus = { - managed: boolean - running: boolean - url: string - command: string - pid?: number - startedAt?: number - error?: string - /** Why this host cannot restart it, when it cannot. */ - reason?: string +export type SkillDoc = { + name: string + folder: string + description?: string + content: string + path: string + legacy?: boolean } +export type SkillSave = { name: string; path: string; backup?: string; error?: string } export const store = { roles: () => request("/roles"), @@ -84,12 +78,7 @@ export const store = { body: JSON.stringify(pipeline), }), deletePipeline: (name: string) => request(`/pipelines/${encodeURIComponent(name)}`, { method: "DELETE" }), - saveAgents: (name: string, agent: Record, merge = false) => - request<{ path: string; merged: boolean; backup?: string; unchanged?: boolean; error?: string }>( - `/pipelines/${encodeURIComponent(name)}/agents${merge ? "?merge=1" : ""}`, - { method: "POST", body: JSON.stringify({ agent }) }, - ), - /** MCP servers configured in the project's opencode.json. */ + /** MCP servers of the OpenFlow Omp profile. */ mcpServers: () => request("/mcp"), saveMcpServer: (server: McpServer) => request<{ name: string; path: string; backup?: string; unchanged?: boolean }>( @@ -101,7 +90,10 @@ export const store = { ), deleteMcpServer: (name: string) => request<{ name: string; removed: boolean }>(`/mcp/${encodeURIComponent(name)}`, { method: "DELETE" }), - /** Skills authored in OpenFlow, stored under `.openflow/skills` and registered in opencode.json. */ + /** + * Skills of the OpenFlow Omp profile. Entries flagged `legacy` live in the old + * `.openflow/skills` folder: listed read-only until saved, which migrates them. + */ skills: () => request("/skills"), skill: (name: string) => request(`/skills/${encodeURIComponent(name)}`), saveSkill: (skill: { name: string; description?: string; content: string }) => @@ -157,24 +149,6 @@ export const store = { */ pruneRuns: (rules: { keep?: number; days?: number }) => request<{ removed: string[]; kept: number }>("/runs/prune", { method: "POST", body: JSON.stringify(rules) }), - /** Providers the opencode CLI already holds keys for. Names only, no secrets. */ - cliKeys: () => request<{ path: string; providers: string[] }>("/cli-keys"), - /** - * Model ids opencode zen actually serves, or `null` when the list could not - * be read — `null` must leave the catalog unfiltered, not empty it. - */ - zenModels: () => request<{ ids: string[] | null }>("/zen-models"), - /** Which of these environment variables the host has set. Names only, no values. */ - envPresent: (names: string[]) => - names.length - ? // Commas stay raw so ~250 provider variables still fit in one URL. - request<{ present: string[] }>(`/env?names=${names.map(encodeURIComponent).join(",")}`) - : Promise.resolve({ present: [] as string[] }), - importCliKeys: (providers?: string[]) => - request<{ results: Array<{ providerID: string; ok: boolean; error?: string }> }>("/cli-keys/import", { - method: "POST", - body: JSON.stringify({ providers }), - }), /** Subdirectories of `target`, or drive/root listing when omitted — for a folder picker. */ browse: (target?: string) => request(`/browse${target ? `?path=${encodeURIComponent(target)}` : ""}`), /** @@ -184,193 +158,6 @@ export const store = { */ pickFolder: (start?: string) => request<{ path: string | null }>("/pick-folder", { method: "POST", body: JSON.stringify({ path: start }) }), - /** - * Which OpenAI-compatible providers the *global* opencode config already - * repackages onto `@ai-sdk/openai-compatible`, and which ones it could. See - * [../../lib/repackage.ts]: without the override this engine's runner cannot - * dispatch an OpenRouter or Groq model however valid its key is. - */ - repackageStatus: () => - request<{ path: string; applied: string[]; available: string[]; error?: string }>("/repackage"), - /** Writes that override for `providers`. The engine must restart before it applies. */ - repackage: (providers: string[]) => - request<{ path: string; changed: string[]; applied: string[]; backup?: string; restart: boolean }>("/repackage", { - method: "POST", - body: JSON.stringify({ providers }), - }), - /** - * Whether the *global* opencode config starts OpenFlow's dispatch MCP server, - * and whether the command it holds still points at this checkout. See - * [../../lib/dispatch-tool.ts]: without it an orchestrator has no `dispatch` - * tool to call and falls back to a fenced block, which measurably loses. - */ - dispatchToolStatus: () => - request<{ path: string; present: boolean; current: boolean; root: string; error?: string }>("/dispatch-tool"), - /** Installs or repoints it. The engine must restart before cards can call it. */ - installDispatchTool: () => - request<{ path: string; present: boolean; current: boolean; changed: boolean; backup?: string; restart: boolean }>( - "/dispatch-tool", - { method: "POST" }, - ), - /** State of the engine this host proxies. */ - serverStatus: () => request("/server"), - /** - * Restarts `opencode serve`, which is the only way a merged agent, a new - * skill or an MCP change becomes visible — the server reads those once at - * boot. Rejects with the status attached when this host does not own the - * process, so the caller can show the command instead. - */ - restartServer: async () => { - const response = await fetch("/flow/api/server/restart", { method: "POST" }) - const body = (await response.json().catch(() => undefined)) as (ServeStatus & { error?: string }) | undefined - if (response.ok) return body as ServeStatus - throw Object.assign(new Error(body?.error ?? `restart failed (${response.status})`), { - status: response.status, - info: body, - }) - }, /** Switches the live project directory. Takes effect immediately, no restart. */ setProject: (path: string) => request("/project", { method: "POST", body: JSON.stringify({ path }) }), } - -/** - * Tool names an `agent.tools` map can switch off, and the permission action - * each one lands on once the server translates the config. - * - * Verified against a running server: `{ tools: { x: false } }` becomes the rule - * `{ action, resource: "*", effect: "deny" }`. `write`, `edit` and `patch` all - * collapse onto the single `edit` action, so they are offered here as one - * toggle — exposing them separately lets a graph ask for contradictory states. - * Unknown names are passed through verbatim and silently do nothing, so this - * list is the only guard against a typo becoming a no-op toggle. - * - * `question` is what lets a node stop and ask a person something: the builtin - * tool registers under that name, so a `question: "deny"` rule strips it from - * the agent and a node with it off runs headless and guesses instead. - */ -export const TOOL_ACTIONS: Record = { - read: "read", - grep: "grep", - glob: "glob", - edit: "edit", - bash: "bash", - webfetch: "webfetch", - websearch: "websearch", - todowrite: "todowrite", - skill: "skill", - question: "question", -} - -export const TOOLS = Object.keys(TOOL_ACTIONS) - -/** - * Plain-language descriptions for the tool checkboxes, the highest-value - * tooltips: these boxes decide whether a node can write, edit or run commands, - * the source of the real-file-write hazard. Keyed by the same names as - * `TOOL_ACTIONS`, so `tool-help.test.ts` can assert full coverage. `edit` - * covers write and patch — they collapse onto one toggle (see `TOOL_ALIASES`). - */ -export const TOOL_HELP: Record = { - read: "Read files in the project", - grep: "Search file contents", - glob: "Find files by name pattern", - edit: "Create, modify, and delete files", - bash: "Run shell commands", - webfetch: "Fetch a web page by URL", - websearch: "Search the web", - todowrite: "Keep a running task list while it works", - skill: "Load a packaged skill's instructions", - question: "Pause and ask you a question", -} - -/** Older graphs (and the role presets) name the edit capability three ways. */ -const TOOL_ALIASES: Record = { - write: "edit", - patch: "edit", - "apply-patch": "edit", -} - -/** - * Folds a node's tool map onto the names the server acts on. Aliases merge, and - * a deny anywhere in a merged group wins — losing a restriction on the way to - * the config would hand an agent more access than the graph asked for. - */ -export function toolMap(tools: Record | undefined) { - const merged: Record = {} - for (const [name, value] of Object.entries(tools ?? {})) { - if (typeof value !== "boolean") continue - const resolved = TOOL_ALIASES[name] ?? name - if (!(resolved in TOOL_ACTIONS)) continue - merged[resolved] = resolved in merged ? merged[resolved] && value : value - } - return merged -} - -/** - * A node's tool toggles as an explicit permission map. - * - * `agent.tools` is marked "@deprecated Use 'permission' field instead" in the - * config schema, and it can only say allow or deny. `permission` is the current - * field and takes "allow" | "ask" | "deny" per action, so every toggle is - * written out explicitly rather than leaning on whatever the default happens to - * be. Actions the graph says nothing about are left off the map entirely and - * keep asking — the engine answers those at runtime. - */ -export function permissionBlock(tools: Record | undefined) { - const block: Record = {} - for (const [name, enabled] of Object.entries(toolMap(tools))) { - block[TOOL_ACTIONS[name]] = enabled ? "allow" : "deny" - } - return block -} - -/** - * Graph -> opencode `agent` config block. Each node becomes one agent whose - * system prompt is the node's role instructions. - * - * The shape is the config's own input vocabulary — `prompt` and `permission`, - * not the `system` and `permissions` that `GET /api/agent` reports back. The - * server translates the former into the latter at load time; feeding it the - * translated form instead gets both fields ignored. - */ -export function agentKey(pipeline: Pipeline, node: Pipeline["nodes"][number]) { - // The node id is part of the key because a role is not unique: two `coder` - // nodes would otherwise collapse onto one agent, and the last one written - // would decide both nodes' permissions — silently handing the restricted one - // whatever the permissive one asked for. - return `${pipeline.name}-${node.role}-${node.id}`.replace(/[^a-zA-Z0-9-_]/g, "-").toLowerCase() -} - -/** - * Per-node MCP access, as permission rules. - * - * An MCP tool is registered as `_`, and permission actions are - * matched by wildcard, so one `"_*"` rule covers a whole server. Rules - * are only written for servers the project actually configures: a blanket deny - * would also silence servers added later, and a node that says nothing about - * MCP (`agent.mcp` undefined — every node authored before this existed) is - * left alone rather than retroactively locked down. - */ -export function mcpBlock(allowed: string[] | undefined, servers: string[]) { - const block: Record = {} - if (!allowed || !servers.length) return block - const set = new Set(allowed) - for (const server of servers) block[`${server}_*`] = set.has(server) ? "allow" : "deny" - return block -} - -export function agentBlock(pipeline: Pipeline, servers: string[] = []) { - const block: Record = {} - for (const node of pipeline.nodes) { - const key = agentKey(pipeline, node) - const permission = { ...permissionBlock(node.agent.tools), ...mcpBlock(node.agent.mcp, servers) } - block[key] = { - mode: "primary", - description: `OpenFlow node ${node.id} (${node.role}) of pipeline ${pipeline.name}`, - ...(node.agent.model ? { model: node.agent.model } : {}), - ...(node.agent.prompt ? { prompt: node.agent.prompt } : {}), - ...(Object.keys(permission).length ? { permission } : {}), - } - } - return block -} diff --git a/packages/flow/src/server/usage.test.ts b/packages/flow/src/server/usage.test.ts index 9f10347c96..dd5560f115 100644 --- a/packages/flow/src/server/usage.test.ts +++ b/packages/flow/src/server/usage.test.ts @@ -19,8 +19,7 @@ import { /** * These numbers are the product's honesty guarantee, so they are pinned * against hand-computed values rather than against whatever the code happens - * to return. The arithmetic mirrors opencode's own — see - * `packages/opencode/src/session/session.ts`. + * to return. */ const tokens = (part: Partial): TokenTotals => ({ ...NO_TOKENS, ...part }) @@ -37,7 +36,7 @@ const sonnet: PricedModel = { ], } -const free: PricedModel = { providerID: "opencode", id: "zen-free", cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }] } +const free: PricedModel = { providerID: "freehost", id: "free-model", cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }] } const unknown: PricedModel = { providerID: "mystery", id: "model-x", cost: [] } const step = (model: string, part: Partial, id: string = crypto.randomUUID()): StepUsage => ({ @@ -97,7 +96,7 @@ describe("summarize", () => { }) test("a genuinely free model costs zero and stays priced", () => { - const spend = summarize([step("opencode/zen-free", { input: 5_000, output: 900 })], catalog(free)) + const spend = summarize([step("freehost/free-model", { input: 5_000, output: 900 })], catalog(free)) expect(spend.cost).toBe(0) expect(spend.unpriced).toEqual([]) expect(spend.models[0].priced).toBe(true) @@ -155,11 +154,11 @@ describe("rollups", () => { test("groups by provider, dearest first", () => { const spend = summarize( - [step("anthropic/claude-sonnet-4", { input: 100_000 }), step("opencode/zen-free", { input: 9_000 })], + [step("anthropic/claude-sonnet-4", { input: 100_000 }), step("freehost/free-model", { input: 9_000 })], catalog(sonnet, free), ) const rows = byProvider(spend) - expect(rows.map((row) => row.provider)).toEqual(["anthropic", "opencode"]) + expect(rows.map((row) => row.provider)).toEqual(["anthropic", "freehost"]) expect(rows[0].cost).toBeCloseTo(0.3, 12) expect(rows[1].cost).toBe(0) }) diff --git a/packages/flow/src/server/usage.ts b/packages/flow/src/server/usage.ts index 5294ebf5fd98eda30ab1ba42c006f5330493a80d..bb1cd8e0a933f368d54d1c6f256f4a423c7b2416 100644 GIT binary patch delta 606 zcmYjP&q^CX7$?SD))Ek*J=K2XqAnXZ?Lj<EMep%GzK`2Sm*&UhuA4wr$gF@&SSzvq)#|+x z1bi7m4E$Lv#Wm(2UH!LpICBMpLEFF7IhK%Vasj+S(3}V-N`1jJqphPjPCzO+`TEhb z8p*ex!Raw7g3ZyV;W4D=rLIz8WQyuSDoleEjkdPoeIx5_GnRhowF?fYG#D#0e3Yzj zmv-KOP(CIt#DztmLRYH64BEY!L`wmeiHs#ZB*^5znhTSVUsnVquD9bDju~ln#Y1Uo z47s^-c;%R5kxj}xu*snTyvh)BwlSuZNC^k`UKT~ZMQLTFLxKsRXUN4S922bInI8FF>;5kN3$1^=ed-iTH7>_{mJEKqzHl>?)Ui?{F Mc{bYoq5E&`KdytybN~PV delta 911 zcmY*YKX21O6j##sO5RH&PEVM{0P@m5)^(p5&bN8H(seA$i za>XZLWXm^TFfPSN-jCJJ>9)_^d5h=4D_A9@mopkxejjt4`S7fKeOnipI9 zHymoQz#Otg_d#kaG}7jsg0%Ut_4uOXXYJ!0XP_AlDyJm+C>nw?@a*ImDs*t`39NJs zfduP;iL+&g+By;?f)pgQJ#S^NEP|_z51I$*!|^F3qEboAmmb7$AHouJINw375enhK z*p-xK*1L0M^}hMkyWV{1?VoZk!YL|BBqgB~BnxDo&fQI3&P^Oea1vyZVgnvEO5&V{ z;CXW`-JzfoIYB#V#P(kEcKh1XOli%j3HnEvt*NRBoFIg6Z!Q^*CsPSI<x`U`49=Tv4>qB!OdYa}Lv~`Q6O(a#VG@iR8m7#7;F@A6 zD4P!8(Y&x4qdqjF-FwSdyN|ZU>tvx)a_ZSm2tgRuX6J2vy5^QBxM+$vAZZQpuXJWP Y# { actions.reset() actions.rejectPermissions() + actions.rejectUpliftFailures() actions.clearNotice() setDefaultModel(undefined) setAvailableModels(new Set()) @@ -171,6 +172,92 @@ describe("runtime", () => { }) }) +describe("uplift, memory and swarm on the runtime", () => { + const failure = (requestID: string, nodeID = "n1") => ({ requestID, nodeID, role: "planner", reason: "timed out", elapsedMs: 1200 }) + + test("patches carry the new fields and keep earlier ones", () => { + const [id] = graph("planner") + actions.patchRuntime(id, { status: "running", prompt: "uplifted text", originalPrompt: "task" }) + actions.patchRuntime(id, { uplift: { status: "ok", original: "task", uplifted: "uplifted text" } }) + actions.patchRuntime(id, { memory: { read: { status: "empty", items: [] } } }) + actions.patchRuntime(id, { + swarm: { + mode: "auto", + active: true, + counts: { queued: 0, running: 1, completed: 0, failed: 0, cancelled: 0 }, + agents: [{ agent: "a05-backend", state: "running", at: 1 }], + updatedAt: 1, + }, + }) + + expect(runtimeOf(id)).toMatchObject({ + status: "running", + prompt: "uplifted text", + originalPrompt: "task", + uplift: { status: "ok", uplifted: "uplifted text" }, + memory: { read: { status: "empty" } }, + swarm: { active: true, agents: [{ agent: "a05-backend" }] }, + }) + }) + + test("resetRuntime drops them with everything else", () => { + const [id] = graph("planner") + actions.patchRuntime(id, { originalPrompt: "task", uplift: { status: "failed", original: "task" }, memory: { graphId: "g" } }) + actions.resetRuntime({ [id]: "queued" }) + + expect(runtimeOf(id)).toEqual({ status: "queued" }) + }) + + test("askUpliftFailure resolves with the chosen value and leaves the queue", async () => { + const answer = actions.askUpliftFailure(failure("r1")) + expect(state.upliftFailures.map((entry) => entry.requestID)).toEqual(["r1"]) + + actions.resolveUpliftFailure("r1", "original") + + expect(await answer).toBe("original") + expect(state.upliftFailures).toEqual([]) + }) + + test("each pending failure resolves with its own choice, oldest first in the queue", async () => { + const first = actions.askUpliftFailure(failure("r1", "n1")) + const second = actions.askUpliftFailure(failure("r2", "n2")) + expect(state.upliftFailures.map((entry) => entry.requestID)).toEqual(["r1", "r2"]) + + actions.resolveUpliftFailure("r2", "retry") + expect(state.upliftFailures.map((entry) => entry.requestID)).toEqual(["r1"]) + actions.resolveUpliftFailure("r1", "cancel") + + expect(await second).toBe("retry") + expect(await first).toBe("cancel") + }) + + test("resolving an unknown request changes nothing", () => { + void actions.askUpliftFailure(failure("r1")) + actions.resolveUpliftFailure("nope", "cancel") + + expect(state.upliftFailures).toHaveLength(1) + }) + + test("rejectUpliftFailures cancels every pending card", async () => { + const all = [actions.askUpliftFailure(failure("r1", "n1")), actions.askUpliftFailure(failure("r2", "n2")), actions.askUpliftFailure(failure("r3", "n3"))] + + actions.rejectUpliftFailures() + + expect(await Promise.all(all)).toEqual(["cancel", "cancel", "cancel"]) + expect(state.upliftFailures).toEqual([]) + }) + + test("a new run settles decisions the previous run left behind", async () => { + const [id] = graph("planner") + const stale = actions.askUpliftFailure(failure("r1", id)) + + actions.resetRuntime({ [id]: "queued" }) + + expect(await stale).toBe("cancel") + expect(state.upliftFailures).toEqual([]) + }) +}) + describe("selection", () => { test("ctrl-click adds a card and clicking it again drops it", () => { const [a, b] = graph("planner", "coder") @@ -391,15 +478,15 @@ describe("undo", () => { }) }) -describe("engine reachability", () => { +describe("host reachability", () => { test("starts optimistic and follows the last probe", () => { - expect(state.engineReachable).toBe(true) + expect(state.hostReachable).toBe(true) - actions.setEngineReachable(false) - expect(state.engineReachable).toBe(false) + actions.setHostReachable(false) + expect(state.hostReachable).toBe(false) - actions.setEngineReachable(true) - expect(state.engineReachable).toBe(true) + actions.setHostReachable(true) + expect(state.hostReachable).toBe(true) }) }) diff --git a/packages/flow/src/state.ts b/packages/flow/src/state.ts index 0f5d26974f..caacfca456 100644 --- a/packages/flow/src/state.ts +++ b/packages/flow/src/state.ts @@ -15,6 +15,9 @@ import type { import { applyEvent } from "./server/activity" import type { QuestionInfo } from "./server/client" import { emptyPipeline, modeOf, type FlowMode, type Gauntlet, type Verify } from "./graph/types" +import type { MemoryInfo } from "../shared/memory" +import type { SwarmStatus } from "../shared/swarm" +import type { UpliftInfo } from "../shared/uplift" import { pathThrough, wouldCycle } from "./graph/validate" export type NodeRuntime = { @@ -32,6 +35,11 @@ export type NodeRuntime = { diff?: CardDiff /** What the card has done, in order — see `NodeEvent`. */ events?: NodeEvent[] + /** The card's own task text before prompt uplift; `prompt` is what was actually sent. */ + originalPrompt?: string + uplift?: UpliftInfo + memory?: MemoryInfo + swarm?: SwarmStatus } export type PendingPermission = { @@ -49,6 +57,16 @@ export type PendingQuestion = { questions: QuestionInfo[] } +export type UpliftChoice = "original" | "retry" | "cancel" + +export type PendingUpliftFailure = { + requestID: string + nodeID: string + role: string + reason: string + elapsedMs: number +} + export type FlowState = { pipeline: Pipeline /** @@ -70,17 +88,21 @@ export type FlowState = { notice?: { kind: "info" | "error"; text: string } permissions: PendingPermission[] questions: PendingQuestion[] + /** Cards parked on a failed prompt uplift, oldest first; the dialog shows the first. */ + upliftFailures: PendingUpliftFailure[] /** Graph edits made since the last load or save — what a replace would destroy. */ dirty: boolean /** - * Whether `opencode serve` answered the last time anything asked it. + * Whether the OpenFlow host (`/flow/api`, the process started by `bun openflow.ts`) + * answered the last time anything asked it. * - * The single source of truth for engine reachability. With the engine down - * every provider read comes back empty, and every surface that reads a model + * The single source of truth for host reachability. With the host down every + * model and provider read comes back empty, and every surface that reads a model * list would otherwise conclude "no provider is connected" — confidently and - * wrongly. Optimistic until the first probe lands, which is milliseconds. + * wrongly. Optimistic until the first probe lands, which is milliseconds. Whether + * `omp` itself is installed is a separate fact (`health.omp.available`). */ - engineReachable: boolean + hostReachable: boolean } const [state, setState] = createStore({ @@ -92,8 +114,9 @@ const [state, setState] = createStore({ attachments: [], permissions: [], questions: [], + upliftFailures: [], dirty: false, - engineReachable: true, + hostReachable: true, }) export { state } @@ -110,6 +133,9 @@ type PermissionReply = "once" | "always" | "reject" /** Same idea for questions: `undefined` is a rejection, not an empty answer. */ const asking = new Map void>() +/** And for uplift failures: the card is parked until one of the three choices comes back. */ +const deciding = new Map void>() + let counter = 0 function nodeID() { counter += 1 @@ -443,8 +469,8 @@ export const actions = { setState("dirty", true) }, - setEngineReachable(reachable: boolean) { - setState("engineReachable", reachable) + setHostReachable(reachable: boolean) { + setState("hostReachable", reachable) }, setInput(input: string) { @@ -578,6 +604,33 @@ export const actions = { setState("questions", []) }, + /** + * Blocks one card on a failed prompt uplift and resolves with the person's + * choice. The dialog shows one failure at a time; every one of them stays + * pending (and its card parked) until it is decided or the run is stopped. + */ + askUpliftFailure(request: PendingUpliftFailure) { + const { promise, resolve } = Promise.withResolvers() + deciding.set(request.requestID, resolve) + setState("upliftFailures", (pending) => [...pending, request]) + return promise + }, + + resolveUpliftFailure(requestID: string, choice: UpliftChoice) { + deciding.get(requestID)?.(choice) + deciding.delete(requestID) + setState("upliftFailures", (pending) => pending.filter((request) => request.requestID !== requestID)) + }, + + /** Stop path: every card still waiting on an uplift decision is cancelled, so none hangs. */ + rejectUpliftFailures() { + for (const [requestID, resolve] of deciding) { + resolve("cancel") + deciding.delete(requestID) + } + setState("upliftFailures", []) + }, + /** Stopping a run must not leave the UI holding unanswered prompts. */ rejectPermissions() { for (const [requestID, resolve] of waiting) { @@ -588,6 +641,8 @@ export const actions = { }, resetRuntime(statuses: Record) { + // Decisions left over from the previous run belong to cards that no longer exist. + actions.rejectUpliftFailures() setState( produce((draft) => { draft.runtime = {} diff --git a/packages/flow/src/styles.css b/packages/flow/src/styles.css index 9947148994..9369038cdb 100644 --- a/packages/flow/src/styles.css +++ b/packages/flow/src/styles.css @@ -1,9 +1,9 @@ /* - * OpenFlow wears opencode's clothes. + * Design tokens and shared surfaces for the OpenFlow page. * - * The flow page is a standalone vite entry — it does not load - * `@opencode-ai/ui`, so the design tokens cannot be imported, only copied. The - * two blocks below are verbatim transcriptions of + * The flow page is a standalone vite entry — it does not load the app's shared + * UI package, so its design tokens cannot be imported, only copied. The two + * blocks below are verbatim transcriptions of * `packages/ui/src/v2/styles/colors.css` (primitives) and the two * `[data-color-scheme]` rules in `packages/ui/src/v2/styles/theme.css` * (semantics). Nothing else in this file may state a colour: every rule @@ -590,7 +590,7 @@ textarea:focus-visible, box-shadow: 0 0 0 1px var(--v2-border-border-focus); } -/* opencode's controls settle rather than move; nothing here animates position */ +/* controls settle rather than move; nothing here animates position */ .btn, .icon-btn, .field, @@ -1184,8 +1184,8 @@ main { } /* sessions --------------------------------------------------------------- - A fourth column, left of the palette, holding the sessions `opencode serve` - already has for this project. It is unmounted rather than hidden when + A fourth column, left of the palette, holding the sessions the host holds + for this project. It is unmounted rather than hidden when collapsed, so the canvas gets the width back and the list stops polling. */ .sessions { @@ -1641,7 +1641,7 @@ main { /* * The grid is drawn at 6% white on the base surface — one shade off invisible. * It exists so a drag has something to read against; the moment it is legible - * as a pattern the canvas stops looking like opencode and starts looking like a + * as a pattern the canvas stops looking like a canvas and starts looking like a * diagramming tool. */ .canvas { @@ -2001,10 +2001,10 @@ main { margin-top: 2px; } -/* opencode menus and dialogs -------------------------------------------- */ +/* menus and dialogs ------------------------------------------------------ */ /* - * `.oc` used to carry opencode's font and a private palette. `body` carries the + * `.oc` used to carry its own font and a private palette. `body` carries the * type now and `:root` carries the tokens, so the class survives only as the * stacking context a floating root needs — the markup still emits it, and a * dialog that shares a stacking context with the canvas is a bug waiting. @@ -2051,8 +2051,8 @@ main { font: inherit; letter-spacing: inherit; text-align: left; - /* opencode's rows do not take the pointer cursor, and they keep a margin so - a row scrolled into view by the keyboard is not flush with the edge */ + /* rows do not take the pointer cursor, and they keep a margin so a row + scrolled into view by the keyboard is not flush with the edge */ cursor: default; scroll-margin: 6px 0; } @@ -2083,8 +2083,8 @@ main { font-weight: 560; } -/* opencode sets row titles a notch above body weight — 530, not the 440 the - rest of the interface runs at */ +/* Row titles sit a notch above body weight — 530, not the 440 the rest of the + interface runs at */ .oc-item-label { flex: 1; min-width: 0; @@ -2337,9 +2337,9 @@ main { /* providers dialog ------------------------------------------------------ */ /* - * Top-anchored, not centred. opencode pins its dialogs to where the top edge - * of a full-height one would sit and lets the body grow downwards, so the - * frame does not jump up and down as a search filters the list under it. + * Top-anchored, not centred. Dialogs are pinned to where the top edge of a + * full-height one would sit and the body grows downwards, so the frame does not + * jump up and down as a search filters the list under it. */ .oc-backdrop { position: fixed; diff --git a/packages/flow/src/ui/activity-drawer.tsx b/packages/flow/src/ui/activity-drawer.tsx index ab8c04bb07..d5e258199a 100644 --- a/packages/flow/src/ui/activity-drawer.tsx +++ b/packages/flow/src/ui/activity-drawer.tsx @@ -2,6 +2,7 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } import type { NodeEvent } from "../graph/types" import { costLabel } from "../server/usage" import { actions, runtimeOf, state } from "../state" +import { swarmLine } from "./card-stages-model" import { IconChevron, IconClose } from "./icons" /** Half the window, which is what "expand the card" means here. */ @@ -94,6 +95,13 @@ export function ActivityDrawer() { {costLabel(runtime()!.usage!)} + + {(line) => ( + + {line().text} + + )} + {runtime()!.activity} diff --git a/packages/flow/src/ui/card-stages-model.test.ts b/packages/flow/src/ui/card-stages-model.test.ts new file mode 100644 index 0000000000..94f243aa8e --- /dev/null +++ b/packages/flow/src/ui/card-stages-model.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, test } from "bun:test" +import type { MemoryInfo } from "../../shared/memory" +import type { SwarmStatus } from "../../shared/swarm" +import type { UpliftInfo } from "../../shared/uplift" +import { + decisionLine, + formatElapsed, + isJevSkip, + memoryAge, + memoryChip, + memoryReadText, + memoryRow, + memoryWriteText, + stageAnnouncement, + stageSnapshot, + swarmCountsText, + swarmIdleText, + swarmLine, + swarmPills, + swarmVisible, + truncate, + upliftChip, + upliftFacts, + upliftFailureView, + upliftPrompts, + wrapFocus, +} from "./card-stages-model" + +const uplift = (patch: Partial): UpliftInfo => ({ status: "ok", original: "do the thing", ...patch }) + +const swarm = (patch: Partial = {}): SwarmStatus => ({ + mode: "auto", + active: true, + counts: { queued: 0, running: 0, completed: 0, failed: 0, cancelled: 0 }, + agents: [], + updatedAt: 1, + ...patch, +}) + +describe("text helpers", () => { + test("truncate flattens whitespace and marks the cut", () => { + expect(truncate("a\n b\tc", 20)).toBe("a b c") + expect(truncate("abcdefghij", 5)).toBe("abcd…") + expect(truncate("abcde", 5)).toBe("abcde") + }) + + test("formatElapsed picks a unit and refuses nonsense", () => { + expect(formatElapsed(420)).toBe("420 ms") + expect(formatElapsed(1234)).toBe("1.2 s") + expect(formatElapsed(125_000)).toBe("2 min 05 s") + expect(formatElapsed(undefined)).toBeUndefined() + expect(formatElapsed(-1)).toBeUndefined() + expect(formatElapsed(Number.NaN)).toBeUndefined() + }) +}) + +describe("upliftChip", () => { + test("nothing without a record", () => { + expect(upliftChip(undefined)).toBeUndefined() + }) + + test("running", () => { + expect(upliftChip(uplift({ status: "running" }))).toMatchObject({ label: "uplifting…", tone: "info", aria: "prompt uplift running" }) + }) + + test("ok", () => { + const chip = upliftChip(uplift({ elapsedMs: 2300 })) + expect(chip).toMatchObject({ label: "uplifted", tone: "ok", aria: "prompt uplifted" }) + expect(chip?.title).toContain("2.3 s") + }) + + test("ok with degraded parts says so and names them", () => { + const chip = upliftChip(uplift({ degraded: ["fill:n3"] })) + expect(chip).toMatchObject({ label: "uplifted ~", tone: "warn", aria: "prompt uplifted with a partial plan" }) + expect(chip?.title).toContain("fill:n3") + }) + + test("a Jev skip is labelled as Jev's call, not a failure", () => { + const chip = upliftChip(uplift({ status: "skipped", reason: "jev-skip" })) + expect(chip).toMatchObject({ label: "skipped (Jev)", tone: "neutral", aria: "prompt uplift skipped by Jev" }) + expect(chip?.title).toContain("not new multi-step work") + }) + + test("another skip reason keeps its own words, unknown ones verbatim", () => { + expect(upliftChip(uplift({ status: "skipped", reason: "disabled" }))).toMatchObject({ label: "uplift skipped" }) + expect(upliftChip(uplift({ status: "skipped", reason: "disabled" }))?.title).toContain("switched off") + expect(upliftChip(uplift({ status: "skipped", reason: "odd-new-reason" }))?.title).toContain("odd-new-reason") + }) + + test("failed carries the reason into the tooltip and the aria string", () => { + const chip = upliftChip(uplift({ status: "failed", reason: "claude CLI is logged out" })) + expect(chip).toMatchObject({ label: "uplift failed", tone: "danger" }) + expect(chip?.title).toBe("nothing was sent — claude CLI is logged out") + expect(chip?.aria).toBe("prompt uplift failed: claude CLI is logged out") + }) + + test("declined means the original went out after a failure", () => { + const chip = upliftChip(uplift({ status: "declined", declined: true, reason: "timeout" })) + expect(chip).toMatchObject({ label: "original sent", tone: "warn" }) + expect(chip?.title).toContain("timeout") + }) + + test("isJevSkip only matches a skipped Jev reason", () => { + expect(isJevSkip(uplift({ status: "skipped", reason: "jev-skip" }))).toBe(true) + expect(isJevSkip(uplift({ status: "failed", reason: "jev-skip" }))).toBe(false) + expect(isJevSkip(uplift({ status: "skipped", reason: "empty" }))).toBe(false) + }) +}) + +describe("uplift section data", () => { + test("facts come in a fixed order and skip what is unknown", () => { + const facts = upliftFacts( + uplift({ source: "llm", graphId: "ut-abc-12345678", nodes: 4, degraded: ["fill:n3"], elapsedMs: 900 }), + ) + expect(facts.map((fact) => fact.key)).toEqual(["status", "planner", "graph id", "nodes", "degraded", "took"]) + expect(facts.find((fact) => fact.key === "nodes")?.value).toBe("4") + expect(upliftFacts(uplift({ status: "failed", reason: "boom" })).map((fact) => fact.key)).toEqual(["status", "reason"]) + }) + + test("a fallback spec is named as such", () => { + expect(upliftFacts(uplift({ source: "fallback" })).find((fact) => fact.key === "planner")?.value).toContain("fallback") + }) + + test("decision lines", () => { + expect(decisionLine({ point: "plan", p: 0.971, action: "promoted" })).toBe("plan · p 0.97 · promoted") + }) + + test("the uplift record's original wins, the runtime copy backs it up", () => { + expect(upliftPrompts("runtime", uplift({ original: "record", uplifted: "" }))).toEqual({ + original: "record", + uplifted: "", + }) + expect(upliftPrompts("runtime", undefined)).toEqual({ original: "runtime", uplifted: undefined }) + expect(upliftPrompts(undefined, undefined)).toEqual({ original: undefined, uplifted: undefined }) + }) + + test("failure dialog copy", () => { + expect(upliftFailureView({ role: "builder", reason: "timed out", elapsedMs: 30_000 }, 0)).toEqual({ + title: "Prompt uplift failed for builder", + reason: "timed out", + elapsed: "after 30.0 s", + queue: undefined, + }) + expect(upliftFailureView({ role: "builder", reason: "x", elapsedMs: 5 }, 1).queue).toBe("1 more card waiting for a decision") + expect(upliftFailureView({ role: "builder", reason: "x", elapsedMs: 5 }, 3).queue).toBe("3 more cards waiting for a decision") + }) +}) + +describe("memoryChip", () => { + const read = (patch: Partial> = {}): NonNullable => ({ + status: "ok", + items: [], + ...patch, + }) + const item = (subject: string) => ({ scope: "repo:o/r", subject, preview: "p", origin: "search" as const }) + + test("nothing without a record", () => { + expect(memoryChip(undefined)).toBeUndefined() + expect(memoryChip({ graphId: "g" })).toBeUndefined() + }) + + test("counts what was read and written", () => { + const chip = memoryChip({ + read: read({ items: [item("openflow/p/r1/a"), item("openflow/p/r1/b")] }), + write: { status: "stored", subject: "openflow/p/r2/c" }, + }) + expect(chip).toMatchObject({ label: "mem ↓2 ↑1", tone: "neutral" }) + expect(chip?.title).toContain("↓ openflow/p/r1/a") + expect(chip?.title).toContain("↑ stored: openflow/p/r2/c") + }) + + test("the tooltip caps the subjects it lists", () => { + const items = Array.from({ length: 8 }, (_, at) => item(`s${at}`)) + const chip = memoryChip({ read: read({ items }) }) + expect(chip?.label).toBe("mem ↓8 ↑0") + expect(chip?.title).toContain("+3 more") + expect(chip?.title).not.toContain("s7") + }) + + test("running shows an ellipsis and the info tone", () => { + expect(memoryChip({ read: read({ status: "running" }) })).toMatchObject({ label: "mem ↓… ↑0", tone: "info" }) + expect(memoryChip({ read: read(), write: { status: "running" } })).toMatchObject({ label: "mem ↓0 ↑…", tone: "info" }) + }) + + test("both sides disabled is 'mem off'", () => { + expect(memoryChip({ read: read({ status: "disabled" }), write: { status: "disabled" } })).toMatchObject({ + label: "mem off", + tone: "neutral", + }) + expect(memoryChip({ read: read({ status: "disabled" }) })?.label).toBe("mem off") + }) + + test("unavailable puts the error in the tooltip, honestly", () => { + const chip = memoryChip({ read: read({ status: "unavailable", error: "substrate refused the connection" }) }) + expect(chip).toMatchObject({ label: "mem unavailable", tone: "warn" }) + expect(chip?.title).toBe("memory unavailable: substrate refused the connection") + expect(chip?.aria).toBe("shared memory unavailable: substrate refused the connection") + }) + + test("a failed read with a stored write keeps the arrows and flags the problem", () => { + const chip = memoryChip({ + read: read({ status: "unavailable", error: "down" }), + write: { status: "stored", subject: "s" }, + }) + expect(chip).toMatchObject({ label: "mem ↓! ↑1", tone: "warn" }) + expect(chip?.title).toContain("memory unavailable: down") + }) + + test("a write that did not land is flagged", () => { + for (const status of ["unverified", "quarantined", "denied", "conflict", "unavailable"] as const) { + expect(memoryChip({ read: read(), write: { status, error: "e" } })).toMatchObject({ label: "mem ↓0 ↑!", tone: "warn" }) + } + expect(memoryChip({ read: read(), write: { status: "skipped" } })).toMatchObject({ label: "mem ↓0 ↑0", tone: "neutral" }) + }) + + test("read and write sentences", () => { + expect(memoryReadText(undefined)).toBeUndefined() + expect(memoryReadText(read({ status: "empty" }))).toBe("no related memories found") + expect(memoryReadText(read({ items: [item("a")] }))).toBe("read 1 memory") + expect(memoryReadText(read({ items: [item("a"), item("b")], briefState: "degraded" }))).toBe("read 2 memories (brief degraded)") + expect(memoryReadText(read({ status: "unavailable" }))).toContain("memory unavailable:") + expect(memoryWriteText({ status: "stored", subject: "openflow/x" })).toBe("stored: openflow/x") + expect(memoryWriteText({ status: "denied", error: "scope not writable" })).toBe("denied by Substrate: scope not writable") + expect(memoryWriteText(undefined)).toBeUndefined() + }) + + test("rows truncate the preview and show age when the host sent one", () => { + const row = memoryRow({ ...item("openflow/p/r/c"), preview: "x".repeat(300), run: "r", card: "c", ageDays: 3 }) + expect(row.preview.length).toBe(140) + expect(row).toMatchObject({ scope: "repo:o/r", subject: "openflow/p/r/c", age: "3 d ago", from: "run r · card c" }) + expect(memoryRow(item("s")).age).toBeUndefined() + expect(memoryAge(0.2)).toBe("today") + }) +}) + +describe("swarm", () => { + test("counts read in a fixed order with 'done' for completed", () => { + expect(swarmCountsText({ queued: 1, running: 2, completed: 3, failed: 1, cancelled: 0 })).toBe("2 running · 1 queued · 3 done · 1 failed") + expect(swarmCountsText({ queued: 0, running: 0, completed: 0, failed: 0, cancelled: 0 })).toBe("") + }) + + test("visible only once something happened or it degraded", () => { + expect(swarmVisible(undefined)).toBe(false) + expect(swarmVisible(swarm({ mode: "off", active: true }))).toBe(false) + expect(swarmVisible(swarm({ active: false }))).toBe(false) + expect(swarmVisible(swarm({ active: true }))).toBe(true) + expect(swarmVisible(swarm({ active: false, error: "preflight failed" }))).toBe(true) + }) + + test("a running swarm", () => { + const status = swarm({ + counts: { queued: 0, running: 1, completed: 1, failed: 0, cancelled: 0 }, + agents: [ + { agent: "a05-backend", state: "running", activity: "edit src/x.ts", at: 1 }, + { agent: "a08-qa", state: "completed", at: 1 }, + ], + }) + const line = swarmLine(status) + expect(line).toMatchObject({ text: "swarm 1 running · 1 done", tone: "info", aria: "agent swarm 1 running · 1 done", more: 0 }) + expect(line?.title).toBe("swarm 1 running · 1 done\na05-backend: running — edit src/x.ts\na08-qa: completed") + expect(line?.pills.map((pill) => pill.agent)).toEqual(["a05-backend", "a08-qa"]) + }) + + test("delegating before any agent reported", () => { + expect(swarmLine(swarm())?.text).toBe("swarm delegating") + }) + + test("a failed lane is the danger tone", () => { + expect(swarmLine(swarm({ counts: { queued: 0, running: 0, completed: 0, failed: 1, cancelled: 0 } }))?.tone).toBe("danger") + }) + + test("degraded shows the error, always", () => { + const line = swarmLine(swarm({ error: "no signing key", active: false })) + expect(line).toMatchObject({ text: "swarm degraded: no signing key", tone: "danger", aria: "agent swarm degraded: no signing key" }) + }) + + test("pills cap and count the rest", () => { + const agents = Array.from({ length: 6 }, (_, at) => ({ agent: `a${at}`, state: "queued" as const, at: 1 })) + const result = swarmPills(agents) + expect(result.pills).toHaveLength(4) + expect(result.more).toBe(2) + }) + + test("idle text depends on the mode", () => { + expect(swarmIdleText(swarm({ mode: "always", active: false }))).toContain("a01-orchestrator") + expect(swarmIdleText(swarm({ mode: "auto", active: false }))).toContain("no delegation yet") + }) +}) + +describe("announcements", () => { + test("a snapshot has one sentence per surface that has state", () => { + expect(stageSnapshot({})).toEqual({ uplift: undefined, memory: undefined, swarm: undefined }) + expect(stageSnapshot({ uplift: uplift({}) }).uplift).toBe("prompt uplifted") + }) + + test("only changed surfaces are announced, and nothing when nothing moved", () => { + const before = stageSnapshot({ uplift: uplift({ status: "running" }) }) + const after = stageSnapshot({ uplift: uplift({}), memory: { read: { status: "empty", items: [] } } }) + expect(stageAnnouncement("builder", before, after)).toBe("builder: prompt uplifted. shared memory — no related memories found") + expect(stageAnnouncement("builder", after, after)).toBe("") + }) + + test("a surface that disappears is not announced", () => { + const before = stageSnapshot({ uplift: uplift({}) }) + expect(stageAnnouncement("builder", before, stageSnapshot({}))).toBe("") + }) +}) + +describe("wrapFocus", () => { + test("Tab moves forward and wraps at the end", () => { + expect(wrapFocus(0, 3, false)).toBe(1) + expect(wrapFocus(2, 3, false)).toBe(0) + }) + + test("Shift+Tab moves back and wraps at the start", () => { + expect(wrapFocus(2, 3, true)).toBe(1) + expect(wrapFocus(0, 3, true)).toBe(2) + }) + + test("focus outside the dialog enters at the near end", () => { + expect(wrapFocus(-1, 3, false)).toBe(0) + expect(wrapFocus(-1, 3, true)).toBe(2) + }) + + test("nothing focusable means nowhere to go", () => { + expect(wrapFocus(-1, 0, false)).toBe(-1) + }) +}) diff --git a/packages/flow/src/ui/card-stages-model.ts b/packages/flow/src/ui/card-stages-model.ts new file mode 100644 index 0000000000..685da5fe97 --- /dev/null +++ b/packages/flow/src/ui/card-stages-model.ts @@ -0,0 +1,386 @@ +import type { MemoryInfo } from "../../shared/memory" +import type { SwarmAgentState, SwarmStatus } from "../../shared/swarm" +import type { UpliftDecision, UpliftInfo } from "../../shared/uplift" + +/** + * Every string the uplift / memory / swarm surfaces show, as data. + * + * There is no DOM test infrastructure in this package, so the components in + * `card-stages.tsx` only place these values; the wording, the counts and the + * "what is honest to say when the planner or Substrate was unreachable" rules + * live here where `bun test` can reach them. + */ + +export type StageTone = "neutral" | "ok" | "info" | "warn" | "danger" + +export type Chip = { + label: string + /** Native tooltip; may span several lines. */ + title: string + /** Full sentence for assistive tech, and for the live region. */ + aria: string + tone: StageTone +} + +/** Longest preview a row shows; the host already cuts memory previews at 240. */ +export const PREVIEW_LIMIT = 140 +/** Memory subjects a chip tooltip lists before saying "+n more". */ +const TITLE_SUBJECTS = 5 +/** Agent pills on a card before the rest collapse into "+n". */ +const PILL_LIMIT = 4 + +/** One line, at most `max` characters, with an ellipsis when something was cut. */ +export function truncate(text: string, max: number) { + const flat = text.replace(/\s+/g, " ").trim() + if (flat.length <= max) return flat + return `${flat.slice(0, Math.max(0, max - 1)).trimEnd()}…` +} + +export function formatElapsed(ms: number | undefined) { + if (ms === undefined || !Number.isFinite(ms) || ms < 0) return undefined + if (ms < 1000) return `${Math.round(ms)} ms` + if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s` + const seconds = Math.round(ms / 1000) + return `${Math.floor(seconds / 60)} min ${String(seconds % 60).padStart(2, "0")} s` +} + +function plural(count: number, word: string) { + return `${count} ${word}${count === 1 ? "" : "s"}` +} + +/* ------------------------------------------------------------------- uplift */ + +const SKIP_REASONS: Record = { + "jev-skip": "Jev judged this is not new multi-step work, so the original prompt was sent", + empty: "the prompt was empty", + disabled: "prompt uplift is switched off", + passthrough: "the prompt asked to be sent as written", + "ultrathink-command": "the prompt already invoked ultrathink", + "child-or-disabled": "uplift does not run for sub-sessions", + "unsupported-host": "the planner does not support this host", + "native-unavailable": "the planner's native host is unavailable", +} + +/** `skipped` is mostly Jev's call; those get their own label so it is clear nobody failed. */ +export function isJevSkip(uplift: UpliftInfo | undefined) { + return uplift?.status === "skipped" && uplift.reason?.startsWith("jev") === true +} + +export function upliftReasonText(reason: string | undefined) { + if (!reason) return undefined + return SKIP_REASONS[reason] ?? reason +} + +/** Parts of the plan that fell back with `ok` mean a partial plan, which is still sent. */ +export function upliftDegraded(uplift: UpliftInfo | undefined) { + return uplift?.status === "ok" && !!uplift.degraded?.length +} + +export function upliftChip(uplift: UpliftInfo | undefined): Chip | undefined { + if (!uplift) return undefined + const reason = upliftReasonText(uplift.reason) + const took = formatElapsed(uplift.elapsedMs) + const suffix = took ? ` (${took})` : "" + + if (uplift.status === "running") + return { + label: "uplifting…", + title: "prompt uplift is rewriting this card's task before it is sent", + aria: "prompt uplift running", + tone: "info", + } + + if (uplift.status === "ok") { + const partial = upliftDegraded(uplift) + const parts = partial ? `; fell back on ${uplift.degraded!.join(", ")}` : "" + return { + label: partial ? "uplifted ~" : "uplifted", + title: `the uplifted prompt was sent instead of the original${parts}${suffix}`, + aria: partial ? "prompt uplifted with a partial plan" : "prompt uplifted", + tone: partial ? "warn" : "ok", + } + } + + if (uplift.status === "skipped") { + const jev = isJevSkip(uplift) + return { + label: jev ? "skipped (Jev)" : "uplift skipped", + title: `the original prompt was sent as written${reason ? ` — ${reason}` : ""}`, + aria: jev ? "prompt uplift skipped by Jev" : "prompt uplift skipped", + tone: "neutral", + } + } + + if (uplift.status === "declined") + return { + label: "original sent", + title: `uplift failed${reason ? ` (${reason})` : ""}; the original prompt was sent at your request`, + aria: "prompt uplift failed, original prompt sent at your request", + tone: "warn", + } + + return { + label: "uplift failed", + title: `nothing was sent${reason ? ` — ${reason}` : ""}`, + aria: `prompt uplift failed${reason ? `: ${reason}` : ""}`, + tone: "danger", + } +} + +/** Fixed-order key/value rows for the inspector's uplift section. */ +export function upliftFacts(uplift: UpliftInfo): Array<{ key: string; value: string }> { + const facts: Array<{ key: string; value: string }> = [] + const status = upliftChip(uplift) + if (status) facts.push({ key: "status", value: status.label }) + const reason = upliftReasonText(uplift.reason) + if (reason) facts.push({ key: "reason", value: reason }) + if (uplift.source) facts.push({ key: "planner", value: uplift.source === "fallback" ? "fallback spec (engine unreachable)" : "llm" }) + if (uplift.graphId) facts.push({ key: "graph id", value: uplift.graphId }) + if (uplift.nodes !== undefined) facts.push({ key: "nodes", value: String(uplift.nodes) }) + if (uplift.degraded?.length) facts.push({ key: "degraded", value: uplift.degraded.join(", ") }) + const took = formatElapsed(uplift.elapsedMs) + if (took) facts.push({ key: "took", value: took }) + return facts +} + +export function decisionLine(decision: UpliftDecision) { + return `${decision.point} · p ${decision.p.toFixed(2)} · ${decision.action}` +} + +export type UpliftPrompts = { original?: string; uplifted?: string } + +/** + * What the two collapsible blocks hold. `originalPrompt` is the runtime's own + * copy and wins only when the uplift record carries none. + */ +export function upliftPrompts(originalPrompt: string | undefined, uplift: UpliftInfo | undefined): UpliftPrompts { + const original = uplift?.original || originalPrompt + return { original: original || undefined, uplifted: uplift?.uplifted || undefined } +} + +/** Dialog copy for one blocked card. `waiting` counts the failures queued behind it. */ +export function upliftFailureView(failure: { role: string; reason: string; elapsedMs: number }, waiting: number) { + const took = formatElapsed(failure.elapsedMs) + return { + title: `Prompt uplift failed for ${failure.role}`, + reason: failure.reason, + elapsed: took ? `after ${took}` : undefined, + queue: waiting > 0 ? `${plural(waiting, "more card")} waiting for a decision` : undefined, + } +} + +/* ------------------------------------------------------------------- memory */ + +type MemoryRead = NonNullable +export type MemoryRowItem = MemoryRead["items"][number] & { + /** `MemoryInfo` items do not carry an age today; shown when the host adds one. */ + ageDays?: number +} + +/** Write outcomes the user has to hear about: the fact may not exist. */ +const WRITE_PROBLEMS: Record = { + unverified: true, + quarantined: true, + denied: true, + conflict: true, + unavailable: true, +} + +const WRITE_TEXT: Record = { + stored: "stored", + unverified: "sent, but the server did not confirm it was persisted", + quarantined: "held in quarantine by Substrate", + denied: "denied by Substrate", + conflict: "conflicts with an existing fact", + skipped: "nothing to write", + unavailable: "memory unavailable", + disabled: "memory is off", + running: "writing…", +} + +export function memoryAge(ageDays: number | undefined) { + if (ageDays === undefined || !Number.isFinite(ageDays) || ageDays < 0) return undefined + if (ageDays < 1) return "today" + return `${Math.floor(ageDays)} d ago` +} + +export function memoryRow(item: MemoryRowItem) { + const from = item.run && item.card ? `run ${item.run} · card ${item.card}` : undefined + return { + scope: item.scope, + subject: item.subject, + age: memoryAge(item.ageDays), + preview: truncate(item.preview, PREVIEW_LIMIT), + from, + } +} + +function readSymbol(read: MemoryInfo["read"]) { + if (!read) return "–" + if (read.status === "running") return "…" + if (read.status === "unavailable") return "!" + if (read.status === "disabled") return "–" + return String(read.items.length) +} + +function writeSymbol(write: MemoryInfo["write"]) { + if (!write) return "0" + if (write.status === "running") return "…" + if (write.status === "stored") return "1" + if (WRITE_PROBLEMS[write.status]) return "!" + return "0" +} + +/** The honest one-liner for a failed read; never implies the card had memory it did not. */ +export function memoryReadText(read: MemoryRead | undefined) { + if (!read) return undefined + if (read.status === "running") return "reading memory…" + if (read.status === "unavailable") return `memory unavailable: ${read.error ?? "the memory service did not answer"}; the card ran without it` + if (read.status === "disabled") return "memory is off for this run" + if (read.status === "empty") return "no related memories found" + const brief = read.briefState && read.briefState !== "complete" ? ` (brief ${read.briefState})` : "" + return `read ${read.items.length} ${read.items.length === 1 ? "memory" : "memories"}${brief}` +} + +export function memoryWriteText(write: MemoryInfo["write"]) { + if (!write) return undefined + const base = WRITE_TEXT[write.status] ?? write.status + const detail = write.status === "stored" ? write.subject : write.error + return detail ? `${base}: ${detail}` : base +} + +export function memoryChip(memory: MemoryInfo | undefined): Chip | undefined { + const read = memory?.read + const write = memory?.write + if (!read && !write) return undefined + + const writeOff = !write || write.status === "disabled" + if (read?.status === "disabled" && writeOff) + return { label: "mem off", title: "shared memory is off for this run", aria: "shared memory is off", tone: "neutral" } + + if (read?.status === "unavailable" && (!write || write.status === "disabled" || write.status === "unavailable")) { + const error = read.error ?? write?.error ?? "the memory service did not answer" + return { + label: "mem unavailable", + title: `memory unavailable: ${error}`, + aria: `shared memory unavailable: ${error}`, + tone: "warn", + } + } + + const lines: string[] = [] + const readLine = memoryReadText(read) + if (readLine) lines.push(readLine) + for (const item of (read?.items ?? []).slice(0, TITLE_SUBJECTS)) lines.push(` ↓ ${item.subject}`) + const hidden = (read?.items.length ?? 0) - TITLE_SUBJECTS + if (hidden > 0) lines.push(` … +${hidden} more`) + const writeLine = memoryWriteText(write) + if (writeLine) lines.push(`↑ ${writeLine}`) + + const problem = read?.status === "unavailable" || (!!write && WRITE_PROBLEMS[write.status] === true) + const running = read?.status === "running" || write?.status === "running" + const aria = [readLine, writeLine && `write: ${writeLine}`].filter(Boolean).join("; ") + return { + label: `mem ↓${readSymbol(read)} ↑${writeSymbol(write)}`, + title: lines.join("\n"), + aria: `shared memory — ${aria}`, + tone: problem ? "warn" : running ? "info" : "neutral", + } +} + +/* -------------------------------------------------------------------- swarm */ + +const COUNT_ORDER = ["running", "queued", "completed", "failed", "cancelled"] as const + +export function swarmCountsText(counts: SwarmStatus["counts"]) { + const parts: string[] = [] + for (const key of COUNT_ORDER) { + if (counts[key] > 0) parts.push(`${counts[key]} ${key === "completed" ? "done" : key}`) + } + return parts.join(" · ") +} + +/** The card line shows once the swarm did something or degraded; a loaded-but-idle extension is not news. */ +export function swarmVisible(swarm: SwarmStatus | undefined): swarm is SwarmStatus { + if (!swarm || swarm.mode === "off") return false + return swarm.active || swarm.agents.length > 0 || !!swarm.error +} + +export type SwarmPill = { agent: string; state: SwarmAgentState["state"]; title: string } + +/** `agent: state — last activity`, the one form every swarm surface uses. */ +export function swarmAgentText(entry: SwarmAgentState) { + return `${entry.agent}: ${entry.state}${entry.activity ? ` — ${entry.activity}` : ""}` +} + +export function swarmPills(agents: SwarmAgentState[]) { + const pills: SwarmPill[] = agents + .slice(0, PILL_LIMIT) + .map((entry) => ({ agent: entry.agent, state: entry.state, title: swarmAgentText(entry) })) + return { pills, more: Math.max(0, agents.length - PILL_LIMIT) } +} + +export function swarmLine(swarm: SwarmStatus | undefined) { + if (!swarmVisible(swarm)) return undefined + const counts = swarmCountsText(swarm.counts) || "delegating" + const { pills, more } = swarmPills(swarm.agents) + const text = swarm.error ? `swarm degraded: ${swarm.error}` : `swarm ${counts}` + const tone: StageTone = swarm.error || swarm.counts.failed > 0 ? "danger" : swarm.counts.running > 0 ? "info" : "neutral" + return { + text, + title: [text, ...swarm.agents.map(swarmAgentText)].join("\n"), + aria: swarm.error ? `agent swarm degraded: ${swarm.error}` : `agent swarm ${counts}`, + tone, + pills, + more, + } +} + +/** Inspector text for a swarm that loaded but has not delegated. */ +export function swarmIdleText(swarm: SwarmStatus) { + if (swarm.mode === "always") return "swarm is on; waiting for the card to hand work to a01-orchestrator" + return "swarm is on (auto); no delegation yet" +} + +/* ------------------------------------------------------------- live updates */ + +export type StageSnapshot = { uplift?: string; memory?: string; swarm?: string } + +/** One sentence per surface, or nothing when a surface has no state yet. */ +export function stageSnapshot(runtime: { + uplift?: UpliftInfo + memory?: MemoryInfo + swarm?: SwarmStatus +}): StageSnapshot { + return { + uplift: upliftChip(runtime.uplift)?.aria, + memory: memoryChip(runtime.memory)?.aria, + swarm: swarmLine(runtime.swarm)?.aria, + } +} + +/** + * What a screen reader hears when the selected card's stage state moves from + * `prev` to `next`: only the surfaces that changed, nothing when none did. + */ +export function stageAnnouncement(role: string, prev: StageSnapshot, next: StageSnapshot) { + const changed: string[] = [] + for (const key of ["uplift", "memory", "swarm"] as const) { + if (next[key] && next[key] !== prev[key]) changed.push(next[key]) + } + if (!changed.length) return "" + return `${role}: ${changed.join(". ")}` +} + +/* ------------------------------------------------------------------- dialog */ + +/** + * Where Tab or Shift+Tab lands inside a modal with `count` focusable controls. + * `current` is the focused control's index, or -1 when focus is outside the + * dialog, in which case either direction enters at the near end. + */ +export function wrapFocus(current: number, count: number, backwards: boolean) { + if (count <= 0) return -1 + if (current < 0) return backwards ? count - 1 : 0 + return (current + (backwards ? -1 : 1) + count) % count +} diff --git a/packages/flow/src/ui/card-stages.css b/packages/flow/src/ui/card-stages.css new file mode 100644 index 0000000000..3f139fd124 --- /dev/null +++ b/packages/flow/src/ui/card-stages.css @@ -0,0 +1,273 @@ +/* + * Prompt uplift, shared memory and the agent swarm on a card. + * + * Own file so the stages can land without touching styles.css; it only reads + * the tokens that file defines. Tone is a `data-tone` attribute, like the + * status badge's `data-status`. + */ + +/* chips in the card header ---------------------------------------------- */ + +.stage-chip { + flex-shrink: 0; + max-width: 120px; + padding: 0 5px; + overflow: hidden; + border: 0.5px solid var(--v2-border-border-base); + border-radius: 3px; + background: var(--v2-background-bg-layer-02); + color: var(--v2-text-text-faint); + font-size: 11px; + line-height: 16px; + letter-spacing: 0.05px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stage-chip[data-tone="ok"] { + border-color: var(--v2-state-border-success); + background: var(--v2-state-bg-success); + color: var(--v2-state-fg-success); +} + +.stage-chip[data-tone="info"] { + border-color: var(--v2-state-border-info); + background: var(--v2-state-bg-info); + color: var(--v2-state-fg-info); +} + +.stage-chip[data-tone="warn"] { + border-color: var(--v2-state-border-warning); + background: var(--v2-state-bg-warning); + color: var(--v2-state-fg-warning); +} + +.stage-chip[data-tone="danger"] { + border-color: var(--v2-state-border-danger); + background: var(--v2-state-bg-danger); + color: var(--v2-state-fg-danger); +} + +/* memory counts are numbers read against each other, like the diff chip */ +.stage-chip-mem { + font-family: var(--v2-font-family-mono); +} + +/* the header has to be able to wrap once a card carries several chips */ +.node-header:has(.stage-chip) { + flex-wrap: wrap; + row-gap: 4px; +} + +/* swarm line on the card ------------------------------------------------- */ + +.stage-swarm { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + color: var(--v2-text-text-muted); +} + +.stage-swarm[data-tone="info"] { + color: var(--v2-state-fg-info); +} + +.stage-swarm[data-tone="danger"] { + color: var(--v2-state-fg-danger); +} + +.stage-swarm-text { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stage-pills { + display: flex; + flex: 1 1 0; + gap: 4px; + min-width: 0; + overflow: hidden; +} + +.stage-pill { + flex: 0 1 auto; + min-width: 0; + max-width: 96px; + padding: 0 4px; + overflow: hidden; + border: 0.5px solid var(--v2-border-border-muted); + border-radius: 3px; + color: var(--v2-text-text-muted); + font-family: var(--v2-font-family-mono); + font-size: 10px; + line-height: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stage-pill[data-state="running"] { + border-color: var(--v2-state-border-info); + color: var(--v2-state-fg-info); +} + +.stage-pill[data-state="completed"] { + border-color: var(--v2-state-border-success); + color: var(--v2-state-fg-success); +} + +.stage-pill[data-state="failed"] { + border-color: var(--v2-state-border-danger); + color: var(--v2-state-fg-danger); +} + +.stage-pill[data-state="cancelled"] { + border-color: var(--v2-state-border-warning); + color: var(--v2-state-fg-warning); +} + +.stage-pill-more { + flex-shrink: 0; + font-size: 10px; + line-height: 14px; +} + +/* inspector sections ------------------------------------------------------ */ + +.stage-facts { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 2px 10px; + margin: 0; +} + +.stage-facts dt { + color: var(--v2-text-text-faint); + font-size: 11px; + line-height: 16px; +} + +.stage-facts dd { + margin: 0; + overflow-wrap: anywhere; + font-size: 12px; + line-height: 16px; +} + +.stage-details { + margin-top: 6px; +} + +.stage-details > summary { + cursor: pointer; + color: var(--v2-text-text-muted); + font-size: 12px; + line-height: 18px; +} + +.stage-line { + margin: 0; + font-size: 12px; + line-height: 18px; + overflow-wrap: anywhere; +} + +.stage-line[data-tone="warn"] { + color: var(--v2-state-fg-warning); +} + +.stage-line[data-tone="danger"] { + color: var(--v2-state-fg-danger); +} + +.stage-rows { + display: flex; + flex-direction: column; + gap: 6px; + margin: 6px 0 0; + padding: 0; + list-style: none; +} + +.stage-row { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.stage-row-head { + display: flex; + gap: 6px; + min-width: 0; + font-size: 11px; + line-height: 16px; +} + +.stage-row-head > .mono { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stage-row-preview { + color: var(--v2-text-text-muted); + font-size: 12px; + line-height: 16px; + overflow-wrap: anywhere; +} + +/* announcements: present for assistive tech, invisible to everyone else */ +.stage-live { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + border: 0; + clip-path: inset(50%); + white-space: nowrap; +} + +/* uplift failure dialog ------------------------------------------------- */ + +.oc-dialog.uplift-dialog { + width: min(480px, 100%); + min-height: 0; +} + +.uplift-failure-reason { + margin: 0; + padding: 8px 10px; + border-radius: 6px; + background: var(--v2-state-bg-danger); + color: var(--v2-state-fg-danger); + font-family: var(--v2-font-family-mono); + font-size: 12px; + line-height: 18px; + overflow-wrap: anywhere; + user-select: text; +} + +.uplift-failure-meta { + margin: 6px 0 0; +} + +.uplift-failure-actions { + flex-wrap: wrap; + padding: 8px 4px 4px; +} + +@media (prefers-reduced-motion: no-preference) { + .stage-chip, + .stage-pill { + transition: + color 120ms ease, + border-color 120ms ease, + background-color 120ms ease; + } +} diff --git a/packages/flow/src/ui/card-stages.tsx b/packages/flow/src/ui/card-stages.tsx new file mode 100644 index 0000000000..4fdacbb5c0 --- /dev/null +++ b/packages/flow/src/ui/card-stages.tsx @@ -0,0 +1,264 @@ +import { For, Show, createEffect, createSignal } from "solid-js" +import type { MemoryInfo } from "../../shared/memory" +import type { SwarmStatus } from "../../shared/swarm" +import type { UpliftInfo } from "../../shared/uplift" +import { runtimeOf } from "../state" +import { + decisionLine, + memoryChip, + memoryReadText, + memoryRow, + memoryWriteText, + stageAnnouncement, + stageSnapshot, + swarmAgentText, + swarmCountsText, + swarmIdleText, + swarmLine, + upliftChip, + upliftFacts, + upliftPrompts, + type MemoryRowItem, + type StageSnapshot, +} from "./card-stages-model" +import "./card-stages.css" + +/** + * Presentation for what happens to a card's task before and around its Omp + * turn: prompt uplift, shared memory, the swarm. Wording, counts and truncation + * are in `card-stages-model.ts`; these components only place them. + */ + +/** `role="img"` so the full sentence replaces the terse label for assistive tech. */ +export function UpliftChip(props: { uplift?: UpliftInfo }) { + return ( + + {(chip) => ( + + {chip().label} + + )} + + ) +} + +export function MemoryChip(props: { memory?: MemoryInfo }) { + return ( + + {(chip) => ( + + {chip().label} + + )} + + ) +} + +/** One truncating line: counts, then a pill per agent. */ +export function SwarmLine(props: { swarm?: SwarmStatus }) { + return ( + + {(line) => ( + + )} + + ) +} + +/* ---------------------------------------------------------- inspector parts */ + +export function UpliftSection(props: { originalPrompt?: string; uplift?: UpliftInfo }) { + return ( + + {(uplift) => { + const prompts = () => upliftPrompts(props.originalPrompt, uplift()) + return ( +
+

prompt uplift

+
+ + {(fact) => ( + <> +
{fact.key}
+
{fact.value}
+ + )} +
+
+ +
    + + {(decision) =>
  • {decisionLine(decision)}
  • } +
    +
+
+ +
+ original prompt +
{prompts().original}
+
+
+ +
+ uplifted prompt +
{prompts().uplifted}
+
+
+
+ ) + }} +
+ ) +} + +export function MemorySection(props: { memory?: MemoryInfo }) { + return ( + +
+

memory

+ + {(read) => ( + <> +

+ {memoryReadText(read())} +

+ +
    + + {(item) => { + const row = () => memoryRow(item) + return ( +
  • + + {row().subject} + {row().scope} + + {row().age} + + + {row().preview} +
  • + ) + }} +
    +
+
+ + )} +
+ + {(write) => ( + <> +

+ write: {memoryWriteText(write())} +

+ + substrate has no delete: this fact lapses on its ttl, and a forget only stops it being read + + + )} +
+ + {props.memory!.graphId} + +
+
+ ) +} + +export function SwarmSection(props: { swarm?: SwarmStatus }) { + return ( + + {(swarm) => ( +
+

swarm

+ +

+ swarm degraded: {swarm().error} +

+
+ {swarmIdleText(swarm())}} + > +

{swarmCountsText(swarm().counts) || "delegating"}

+
+ +
    + + {(agent) => ( +
  • + + + {agent.state} + + + {agent.agent} + + + + {agent.activity} + +
  • + )} +
    +
+
+ + {swarm().correlationId} + +
+ )} +
+ ) +} + +/** + * Announces changes in uplift, memory and swarm state for one card. + * + * Mounted once, for the card the inspector shows: other cards stay silent. + * Changing which card that is only re-baselines, so moving the selection reads + * nothing out. + */ +export function StageAnnouncer(props: { nodeID: string; role: string }) { + const [text, setText] = createSignal("") + let last: { id: string; snapshot: StageSnapshot } | undefined + + createEffect(() => { + const snapshot = stageSnapshot(runtimeOf(props.nodeID)) + const previous = last?.id === props.nodeID ? last.snapshot : undefined + last = { id: props.nodeID, snapshot } + setText(previous ? stageAnnouncement(props.role, previous, snapshot) : "") + }) + + return ( +
+ {text()} +
+ ) +} diff --git a/packages/flow/src/ui/engine-dialog.tsx b/packages/flow/src/ui/engine-dialog.tsx deleted file mode 100644 index cc54f8536f..0000000000 --- a/packages/flow/src/ui/engine-dialog.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { Show, createSignal } from "solid-js" -import type { ServeStatus } from "../server/store" -import { IconClose } from "./icons" - -/** - * How to restart the engine, whoever owns it. - * - * OpenFlow only spawns `opencode serve` when asked to (`FLOW_MANAGE_SERVER=1`). - * When it did, this offers the restart directly. When it did not — started from - * a terminal or a launcher — no click here can reach that process, because the - * server has no shutdown route, so the dialog names the owner and hands over - * the exact command instead of pretending. - */ -export function EngineDialog(props: { - /** `cwd` rides along from `lib/opencode-process.ts`; the store's copy of the type has not caught up. */ - status: ServeStatus & { cwd?: string } - /** Present when this host owns the engine — the dialog can then just do it. */ - onRestart?: () => void - restarting?: boolean - /** Why the dialog was opened, when it was not the button itself. */ - because?: string - onClose: () => void -}) { - const [copied, setCopied] = createSignal(false) - - // The command is relative (`--cwd packages/opencode`), so it only resolves from - // the repo root; and `serve` refuses to bind a port another process still holds. - // Both are the usual reasons a copy-pasted restart fails, so spell them out and - // hand over ready-to-run `cd` + port-kill lines for either shell. - const port = () => { - const parsed = Number.parseInt(new URL(props.status.url).port, 10) - return Number.isNaN(parsed) ? 4096 : parsed - } - const killPowershell = () => - `Get-NetTCPConnection -LocalPort ${port()} -State Listen | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }` - const killCmd = () => - `for /f "tokens=5" %P in ('netstat -ano ^| findstr :${port()} ^| findstr LISTENING') do taskkill /PID %P /F` - const killUnix = () => `lsof -ti tcp:${port()} | xargs kill` - const cd = () => `cd ${props.status.cwd ?? ""}` - - async function copy() { - // Both lines, in the order they have to run: the command is relative, so the - // `cd` has to travel with it or the paste fails. - const written = await navigator.clipboard - .writeText(`${cd()}\n${props.status.command}`) - .then(() => true) - .catch(() => false) - if (!written) return - setCopied(true) - setTimeout(() => setCopied(false), 1500) - } - - return ( -
event.target === event.currentTarget && props.onClose()}> -
-
-

Restart the engine

- -
-
- -

{props.because}

-
- - -
- OpenFlow started this engine, so it can restart it for you. - -
-
- -

- - OpenFlow did not start opencode serve, so it cannot restart it —{" "} - {props.status.reason ?? "it belongs to whichever process launched it"}. Restart it with the launcher - below, or by hand. - - } - > - Or run it yourself, with the launcher below: - -

- -

- The supported way is the launcher — it frees a port a dead run left bound, reuses an engine that is already - answering instead of starting a second, and brings the canvas up too. From{" "} - {props.status.cwd ?? "your OpenFlow checkout"}: -

-
bun openflow.ts
-

- .\openflow.ps1 and ./openflow.sh are shims over that same file. Both take{" "} - -Manage / -m, which hands the engine to OpenFlow and makes this dialog a one-click - restart. -

- -

Or by hand, in three steps:

- -

- 1. Go to the OpenFlow repo root (the command is relative and only resolves there): -

-
{cd()}
- -

- 2. Stop the engine still on port {port()} — Ctrl+C in the terminal that owns it, or, if you - cannot find it, free the port with the line for your shell: -

-
{killPowershell()}
-
{killCmd()}
-
{killUnix()}
-

- First line is Windows PowerShell, second is Windows cmd, third is macOS/Linux. Skipping this leaves the old - engine bound to the port and serve fails to start. -

- -

3. Start it again:

-
{props.status.command}
-
- - engine {props.status.url} · {props.status.running ? "answering" : "not answering"} - - -
-

- Use this exact command. A globally installed opencode on PATH can be a different version than - this checkout, and an older engine answers the routes OpenFlow needs differently — which looks like a - broken canvas rather than a version mismatch. -

-

- To let OpenFlow own it — and make this a one-click restart — start the canvas with{" "} - FLOW_MANAGE_SERVER=1 and no engine already on that port: -

-
$env:FLOW_MANAGE_SERVER=1; bun openflow.ts
- {/* cmd: `set X=1 && …` stores "1 " with the trailing space, and the check is `=== "1"`, - so the set stays on its own line rather than chained. */} -
{"set FLOW_MANAGE_SERVER=1\nbun openflow.ts"}
-
FLOW_MANAGE_SERVER=1 bun openflow.ts
-

- Again PowerShell, then cmd, then macOS/Linux. .\openflow.ps1 -Manage and{" "} - ./openflow.sh -m set it for you. -

- {/* the 409 body repeats `reason` as `error`; showing both reads as two problems */} - -

last error: {props.status.error}

-
-
-
-
- ) -} diff --git a/packages/flow/src/ui/icons.tsx b/packages/flow/src/ui/icons.tsx index 9730bc753b..fb99845301 100644 --- a/packages/flow/src/ui/icons.tsx +++ b/packages/flow/src/ui/icons.tsx @@ -267,3 +267,12 @@ export function IconQuestion() { ) } + +export function IconTerminal() { + return ( + + + + + ) +} diff --git a/packages/flow/src/ui/inspector.tsx b/packages/flow/src/ui/inspector.tsx index 32662d9294..5eb3350f50 100644 --- a/packages/flow/src/ui/inspector.tsx +++ b/packages/flow/src/ui/inspector.tsx @@ -2,16 +2,16 @@ import { For, Show, createMemo, createSignal } from "solid-js" import { upstream } from "../graph/validate" import * as api from "../server/client" import type { ProviderRow } from "../server/providers" -import { TOOLS, TOOL_HELP } from "../server/store" +import { TOOLS } from "../server/card-spec" +import { TOOL_HELP } from "./tool-help" import { actions, primary, runtimeOf, state } from "../state" import { Attachments, readFiles } from "./attachments" +import { MemorySection, StageAnnouncer, SwarmSection, UpliftSection } from "./card-stages" import { IconTrash } from "./icons" import { ModelPicker } from "./model-picker" -import { Select } from "./select" import { costLabel, formatCost, formatTokens } from "../server/usage" export function Inspector(props: { - agents: string[] providers: ProviderRow[] /** MCP servers the project configures, by name. */ mcpServers: string[] @@ -28,10 +28,9 @@ export function Inspector(props: { /** * Sends one throwaway prompt to the selected model. * - * A model being listed does not mean the account may use it — the zen - * catalog advertises models that answer `401 Model ... is not supported` — - * and a stored key is never verified when it is saved. This is the only - * thing that answers "does this actually work" short of a full run. + * A model being listed does not mean the account may use it, and a login is + * never verified when it is saved. This is the only thing that answers + * "does this actually work" short of a full run. */ /** Reads picked files into data URLs and pins them to the node. */ async function attach(id: string, files: File[]) { @@ -54,12 +53,13 @@ export function Inspector(props: { select a node to edit it
}> {(selected) => ( <> +

node

- {state.selection.length} cards selected — role, model, agent, prompt and tools apply to + {state.selection.length} cards selected — role, model, prompt and tools apply to all of them
@@ -81,7 +81,7 @@ export function Inspector(props: {
- + model
- blank runs on the agent's own default + blank runs on Omp's default model
-
- - agent - - setField(field.path, event.currentTarget.checked)} + /> + {field.label} + + } + > + + setField(field.path, event.currentTarget.value)} + /> + } + > + + + + +

+ {field.hint} +

+
+ +

+ {error()} +

+
+
+ ) + } + + function CardsTab() { + return ( + void adoptSettings(loadSettings())}> + {(view) => ( +
+

How every card's omp session is started. Saved on this machine; no secrets are stored here.

+ {(warning) =>

{warning}

}
+ + {(section) => ( +
+ {section.title} + {(field) => } +
+ )} +
+ + + +
+

+ What cards use today (saved values) +

+
    + + {(line) => ( +
  • + {line.label}: {line.text} +
  • + )} +
    +
+
+
+ + + + + {saved() || (dirty() ? "Unsaved changes." : "")} + +
+
+ )} +
+ ) + } + + function OptionRow(item: { entry: OmpConfigEntry }) { + const entry = item.entry + const rid = `omp-opt-${entry.key.replace(/[^A-Za-z0-9_-]/g, "_")}` + const input = entryInput(entry) + const error = () => rowErrors()[entry.key] + const rowDirty = () => input !== "readonly" && entryDirty(entry, draftOf(entry)) + const busy = () => busyRows()[entry.key] === true + const describe = () => [error() ? `${rid}-error` : undefined, entry.description ? `${rid}-desc` : undefined].filter(Boolean).join(" ") || undefined + const text = () => String(draftOf(entry)) + return ( +
  • +
    + + {entry.key} + {entry.type} + + set + + + + + {entry.description} + + +
    +
    + + {displayValue(entry)} + {readOnlyReason(entry)} +
    + + +
    +
    + + + + + + + + + setRowDraft(entry, event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && rowDirty()) { + event.preventDefault() + saveRow(entry) + } + }} + /> + + +

    + {error()} +

    +
    +
    + + +
    +
    +
    +
  • + ) + } + + function OptionsTab() { + return ( +
    + + void loadConfig()} + empty={(entries) => entries.length === 0} + emptyText="omp reported no options." + > + {(entries) => { + const matches = createMemo(() => searchEntries(entries, query())) + const groups = createMemo(() => groupEntries(matches())) + return ( + <> +

    + {matches().length} of {entries.length} options +

    + +

    No option matches “{query()}”.

    +
    + + {(group, index) => { + const open = () => query().trim() !== "" || openGroups()[group.group] === true + const listId = `omp-group-${index()}` + return ( +
    +

    + +

    + +
    + ) + }} +
    + + ) + }} +
    +
    + ) + } + + return ( +
    event.target === event.currentTarget && props.onClose()}> + +
    + ) +} diff --git a/packages/flow/src/ui/providers-panel.tsx b/packages/flow/src/ui/providers-panel.tsx deleted file mode 100644 index 84509d766f..0000000000 --- a/packages/flow/src/ui/providers-panel.tsx +++ /dev/null @@ -1,347 +0,0 @@ -import { For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js" -import * as api from "../server/client" -import { - isConnected, - searchProviders, - splitProviders, - usableCount, - type ProviderRow, -} from "../server/providers" -import { store } from "../server/store" -import { state } from "../state" -import { IconBack, IconCheck, IconClose, IconSearch } from "./icons" - -/** - * Connect a provider, in opencode's two steps. - * - * `packages/app/src/components/dialog-connect-provider.tsx` does exactly this: - * pick from a searchable list (featured providers first, everything else - * after), then a single field for that provider's API key. Nothing else is on - * screen at either step, which is the whole point — a fresh install has no - * models to choose from, so the only thing to do is choose a provider. - * - * The key goes to `POST /api/integration/:id/connect/key`, the store the model - * catalog actually reads. `opencode providers login` writes somewhere else - * (`auth.json`), which is why an account can hold four CLI keys and still see - * nothing here — hence the import row. Nothing validates a key on the way in: - * the server stores the string it is given, so a typo surfaces later as a 401 - * mid-run. - */ -export function ProvidersPanel(props: { - rows: ProviderRow[] - /** Seeded when the dialog is opened from a model search that found nothing. */ - initialQuery?: string - onClose: () => void - onChanged: () => Promise | void - onNotice: (kind: "info" | "error", text: string) => void - /** Restarts `opencode serve`, when this host owns it — a repackage needs one. */ - onRestart?: () => Promise | void -}) { - const [query, setQuery] = createSignal(props.initialQuery ?? "") - const [picked, setPicked] = createSignal() - const [key, setKey] = createSignal("") - const [busy, setBusy] = createSignal(false) - const [error, setError] = createSignal() - const [cli, setCli] = createSignal<{ path: string; providers: string[] }>() - const [repack, setRepack] = createSignal<{ path: string; applied: string[]; available: string[]; error?: string }>() - - const matched = createMemo(() => searchProviders(props.rows, query())) - const connected = createMemo(() => matched().filter((row) => row.unlocked)) - const rest = createMemo(() => splitProviders(matched().filter((row) => !row.unlocked))) - const provider = createMemo(() => props.rows.find((row) => row.id === picked())) - const importable = createMemo(() => { - const found = cli()?.providers ?? [] - const keyed = new Set(props.rows.filter(isConnected).map((row) => row.id)) - return found.filter((id) => !keyed.has(id)) - }) - - /** - * Connected providers whose models this engine cannot dispatch until the - * global config repackages them onto `@ai-sdk/openai-compatible`. Only the - * connected ones: repackaging a provider nobody has a key for fixes nothing - * and edits a file for no reason. See [../../lib/repackage.ts]. - */ - const repackageable = createMemo(() => { - const status = repack() - if (!status || status.error) return [] - const applied = new Set(status.applied) - const offered = new Set(status.available) - return props.rows.filter((row) => row.unlocked && offered.has(row.id) && !applied.has(row.id)).map((row) => row.id) - }) - - onMount(async () => { - setCli(await store.cliKeys().catch(() => undefined)) - setRepack(await store.repackageStatus().catch(() => undefined)) - }) - - /** - * Escape backs out one step, then closes — the same two-step retreat - * opencode's dialog gives you, so a mistyped provider does not force a - * round trip through the mouse. - */ - const onKey = (event: KeyboardEvent) => { - if (event.key !== "Escape") return - event.stopPropagation() - if (picked()) return setPicked(undefined) - props.onClose() - } - document.addEventListener("keydown", onKey) - onCleanup(() => document.removeEventListener("keydown", onKey)) - - function open(row: ProviderRow) { - setPicked(row.id) - setKey("") - setError(undefined) - } - - async function save(event: Event) { - event.preventDefault() - const row = provider() - if (!row) return - const value = key().trim() - if (!value) return setError("Enter an API key.") - setBusy(true) - setError(undefined) - try { - await api.connectKey(row.id, value, "openflow") - await props.onChanged() - props.onNotice("info", `${row.name} connected — the key is not verified until a model runs`) - setPicked(undefined) - } catch (failure) { - setError(api.describe(failure)) - } finally { - setBusy(false) - } - } - - async function remove(row: ProviderRow) { - setBusy(true) - try { - for (const connection of row.connections) await api.removeCredential(connection.id) - await props.onChanged() - props.onNotice("info", `removed the stored key for ${row.name}`) - setPicked(undefined) - } catch (failure) { - setError(api.describe(failure)) - } finally { - setBusy(false) - } - } - - async function importCli() { - setBusy(true) - try { - const { results } = await store.importCliKeys(importable()) - await props.onChanged() - const failed = results.filter((result) => !result.ok) - if (failed.length) { - props.onNotice( - "error", - `imported ${results.length - failed.length}/${results.length}; failed: ${failed - .map((result) => `${result.providerID} (${result.error})`) - .join(", ")}`, - ) - return - } - props.onNotice("info", `imported ${results.length} key${results.length === 1 ? "" : "s"} from the opencode CLI`) - } catch (failure) { - props.onNotice("error", api.describe(failure)) - } finally { - setBusy(false) - } - } - - /** - * Writes the override and restarts the engine, because config is read once at - * boot — without the restart the models stay exactly as unroutable as before. - */ - async function applyRepackage() { - setBusy(true) - try { - const result = await store.repackage(repackageable()) - setRepack(await store.repackageStatus().catch(() => undefined)) - props.onNotice("info", `repackaged ${result.changed.join(", ")} in ${result.path}`) - if (result.restart && props.onRestart) await props.onRestart() - await props.onChanged() - if (result.restart && !props.onRestart) - props.onNotice("info", "restart `opencode serve` for the repackaged providers to dispatch") - } catch (failure) { - props.onNotice("error", api.describe(failure)) - } finally { - setBusy(false) - } - } - - return ( -
    event.target === event.currentTarget && props.onClose()}> -
    -
    - - - -

    {provider() ? `Connect ${provider()!.name}` : "Connect a provider"}

    - -
    - - }> - {(row) => } - -
    -
    - ) - - function ProviderList() { - return ( - <> - - - -
    - - The opencode CLI holds {importable().length} key{importable().length === 1 ? "" : "s"} this server does not - use ({importable().join(", ")}). - - -
    -
    - - -
    - - This engine cannot dispatch {repackageable().join(", ")} — its runner routes only OpenAI, Anthropic and - OpenAI-compatible APIs. Repackaging rewrites them as OpenAI-compatible in {repack()?.path}. - - -
    -
    - -
    - -
    Connected
    - {(row) => } -
    - -
    Popular
    - {(row) => } -
    - -
    All providers
    - {(row) => } -
    - {/* - An unreachable engine leaves `rows` empty, which used to render as - `No provider matches ""` — blaming an empty search box for a dead - server. The catalog being unreadable is a different sentence. - */} - -
    - No provider matches “{query()}”.} - > - No providers to show — `opencode serve` is not answering, so the catalog could not be read. Start or - restart the engine, then reopen this dialog. - -
    -
    -
    - - ) - } - - function Row(item: { row: ProviderRow }) { - return ( - - ) - } - - function KeyForm(item: { row: ProviderRow }) { - return ( -
    -

    - Paste an API key for {item.row.name}. It is stored by the running opencode server.} - > - {item.row.name} is already authenticated by {item.row.envSet.join(", ")}. A key stored here takes over. - -

    - - - - {(text) =>

    {text()}

    }
    - - -

    - This provider takes no key — it comes from the catalog itself, and its models appear only when the server - authenticates it another way. -

    -
    - -
    - - - - -
    - -

    - Keys take effect immediately — no restart. They are not checked here; use test next to a - node's model to find out whether one answers. -

    -
    - ) - } -} diff --git a/packages/flow/src/ui/role-editor.tsx b/packages/flow/src/ui/role-editor.tsx index 0fbe814d75..0c3d5b40de 100644 --- a/packages/flow/src/ui/role-editor.tsx +++ b/packages/flow/src/ui/role-editor.tsx @@ -1,6 +1,6 @@ import { For, Show, createSignal } from "solid-js" import { allRoles, removeCustomRole, saveCustomRole, type Role } from "../graph/roles" -import { TOOLS } from "../server/store" +import { TOOLS } from "../server/card-spec" import { IconClose, IconTrash } from "./icons" const DEFAULT_COLOR = "#9ad1f0" diff --git a/packages/flow/src/ui/select.tsx b/packages/flow/src/ui/select.tsx index 6ae3e859de..e82b35ba5a 100644 --- a/packages/flow/src/ui/select.tsx +++ b/packages/flow/src/ui/select.tsx @@ -15,14 +15,13 @@ export type SelectOption = { } /** - * One dropdown, built as opencode's own menus are. + * One dropdown, shared by every menu in OpenFlow. * - * The native `` cannot be styled into the popover look used across the + * app — its list is drawn by the OS — so every menu goes through this instead: * a 28px trigger, a floating panel with 4px radius rows, a check on the row - * that is selected, and ↑/↓/Enter/Escape wired the way - * `packages/app/src/components/dialog-select-model.tsx` wires them. Search only - * appears when the list is long enough to need it. + * that is selected, and ↑/↓/Enter/Escape navigation. Search only appears when + * the list is long enough to need it. * * It is fully controlled. Pass `value=""` on menus that are actions rather than * state (open a pipeline, open a run) and nothing sticks after the pick. @@ -44,7 +43,7 @@ export function Select(props: { variant?: "field" | "ghost" placement?: "bottom" | "top" align?: "start" | "end" - /** Menu width in px. Defaults to opencode's 284px popover. */ + /** Menu width in px. Defaults to the 284px popover the model picker uses. */ width?: number /** Force the search row on or off. Default: on above 8 options. */ search?: boolean @@ -233,7 +232,7 @@ export function Select(props: { title={option.title ?? option.label} disabled={option.disabled} // Keeps the click from pulling focus off the search - // row, the way opencode's own rows do. + // row. onPointerDown={(event) => event.preventDefault()} onMouseEnter={() => !option.disabled && setActive(option.value)} onClick={() => choose(option.value)} diff --git a/packages/flow/src/ui/sessions-panel.tsx b/packages/flow/src/ui/sessions-panel.tsx index 95c610bbb0..001f5b55b3 100644 --- a/packages/flow/src/ui/sessions-panel.tsx +++ b/packages/flow/src/ui/sessions-panel.tsx @@ -7,11 +7,10 @@ import { IconBack, IconRestart, IconSearch } from "./icons" /** * The sessions column. * - * Every OpenFlow node is one `opencode serve` primary session, so the sessions - * this lists are not a parallel history OpenFlow keeps — they are the rows - * already in OpenCode's sqlite database, read back through `GET /api/session`. - * A session started from the CLI or TUI in the same project shows up here too, - * and one started by a card shows up in those. There is exactly one store. + * Every OpenFlow node is one Omp session owned by the host, and the rows listed + * here come from `api.sessions()` with a session's turns from + * `api.sessionTranscript()` — not from a parallel history kept in the browser. + * A session a card started shows up in the list while the host holds it. * * The panel is collapsible because the canvas is the app: 240px is cheap on a * wide screen and not on a laptop, and a history sidebar is a thing you consult diff --git a/packages/flow/src/ui/skills-panel.tsx b/packages/flow/src/ui/skills-panel.tsx index 8d967ad24a..05f43b5acc 100644 --- a/packages/flow/src/ui/skills-panel.tsx +++ b/packages/flow/src/ui/skills-panel.tsx @@ -6,15 +6,13 @@ import { IconBack, IconClose, IconPlus, IconTrash } from "./icons" * Author skills without leaving OpenFlow. * * A skill is a folder with a `SKILL.md` (frontmatter `name`/`description`, plus a - * markdown body). The server only scans `.opencode/skill`(`s`) on its own, so - * this panel writes into `.openflow/skills` and has the store register that - * folder in the project `opencode.json` the first time — see `writeSkill` in - * `lib/store.ts`. + * markdown body). Skills are written to the OpenFlow Omp profile's skills + * directory, which Omp reads when a card's session starts — so a save applies to + * the next card session, never one already running. * - * The one sharp edge is reload: `opencode serve` reads its config and skill - * sources once at boot, so a skill saved here does not appear inside a card - * until the server is restarted. Every save says so rather than pretending the - * skill is live — the same honesty the agent-merge flow already keeps. + * Entries from the old `.openflow/skills` folder come back flagged `legacy`. + * They stay listed so nothing silently disappears, open read-only, and move to + * the profile when saved. */ export function SkillsPanel(props: { onClose: () => void @@ -25,7 +23,7 @@ export function SkillsPanel(props: { const [busy, setBusy] = createSignal(false) const [error, setError] = createSignal() - type Draft = { original?: string; name: string; description: string; content: string } + type Draft = { original?: string; name: string; description: string; content: string; legacy?: boolean } async function refresh() { setSkills(await store.skills().catch(() => [])) @@ -55,6 +53,7 @@ export function SkillsPanel(props: { // The folder, not the display name — it is what the API addresses and // what `save` deletes if the name changes. original: doc.folder ?? entry.name, + legacy: entry.legacy, name: doc.name ?? entry.name, description: doc.description ?? "", content: doc.content ?? "", @@ -85,18 +84,14 @@ export function SkillsPanel(props: { // one. Drop the folder it came from, or the old copy stays on disk and the // agent sees two skills with the same body. let renamed = "" - if (draft.original && draft.original !== result.name) { + if (draft.original && !draft.legacy && draft.original !== result.name) { await store.deleteSkill(draft.original) renamed = ` (renamed from ${draft.original})` } await refresh() setEditing(undefined) - const registered = result.registered ? " and registered the skills folder in opencode.json" : "" const warn = result.error ? ` — but ${result.error}` : "" - props.onNotice( - "info", - `saved ${result.name}${renamed}${registered}${warn}. Restart \`opencode serve\` for cards to see it.`, - ) + props.onNotice("info", `saved ${result.name}${renamed}${warn}. It applies to the next card session.`) } catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)) } finally { @@ -116,7 +111,7 @@ export function SkillsPanel(props: { await store.deleteSkill(draft.original) await refresh() setEditing(undefined) - props.onNotice("info", `deleted ${draft.original}. Restart \`opencode serve\` to drop it from cards.`) + props.onNotice("info", `deleted ${draft.original}. It is gone from the next card session.`) } catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)) } finally { @@ -155,7 +150,10 @@ export function SkillsPanel(props: { return ( <>
    - Skills live in .openflow/skills and load into every card the agent's permissions allow. + + Skills live in the OpenFlow Omp profile's skills directory. Omp loads them when a card's session starts, so a + change applies to the next card session. + @@ -168,6 +166,11 @@ export function SkillsPanel(props: { {entry.description} + + + legacy — save to migrate + + )} @@ -220,9 +223,9 @@ export function SkillsPanel(props: {
    - + @@ -230,7 +233,9 @@ export function SkillsPanel(props: {

    - Skills load at server start. After saving, restart opencode serve for a card to pick this up. + {item.draft.legacy + ? "This skill comes from the old .openflow/skills folder and is shown read-only. Save it to copy it into the profile; the next card session then loads it." + : "Skills are read when a card's Omp session starts. A change applies to the next card session, not to one already running."}

    ) diff --git a/packages/flow/src/ui/spend-panel.tsx b/packages/flow/src/ui/spend-panel.tsx index dc8b85faf9..ddd712fe22 100644 --- a/packages/flow/src/ui/spend-panel.tsx +++ b/packages/flow/src/ui/spend-panel.tsx @@ -8,7 +8,7 @@ import { IconClose } from "./icons" /** * What the agents have cost, and how that number was arrived at. * - * Every figure is `tokens the server reported × the price models.dev publishes` + * Every figure is `tokens Omp reported × the price its model catalog reports` * — see `server/usage.ts`. The panel is deliberately explicit about the two * ways it can be short of the truth, because a spend readout nobody trusts is * worth nothing: @@ -84,10 +84,11 @@ export function SpendPanel(props: { onClose: () => void }) {

    - Token counts come from the provider's own usage report, relayed by opencode serve. Money is - those counts at the list price models.dev publishes for each model, including cached and reasoning tokens. - It does not know about subscriptions, credits, free tiers or negotiated rates, and a step that failed - before reporting usage is not counted. Treat it as a close estimate of list-price spend, not as an invoice. + Token counts come from the provider's own usage report, relayed by Omp. Money is those counts at the + per-model price in the catalog Omp reports, including cached and reasoning tokens. A model with no price + there is shown as unpriced, with tokens but no money — never as free. It does not know about + subscriptions, credits, free tiers or negotiated rates, and a step that failed before reporting usage is + not counted. Treat it as a close estimate of list-price spend, not as an invoice.

    diff --git a/packages/flow/src/ui/terminal-dock.css b/packages/flow/src/ui/terminal-dock.css new file mode 100644 index 0000000000..3683ca02f5 --- /dev/null +++ b/packages/flow/src/ui/terminal-dock.css @@ -0,0 +1,171 @@ +/* Terminal dock ---------------------------------------------------------- */ + +/* + * The activity drawer's shell, for a shell: a row of the app column between the + * canvas and the status bar, not an overlay, so the graph keeps its own space. + * The grip and the bar are the drawer's, restated here because styles.css is not + * this component's to edit. + */ +.terminal-dock { + position: relative; + display: flex; + flex-direction: column; + flex-shrink: 0; + min-height: 0; + max-height: 70vh; + border-top: 1px solid var(--v2-border-border-muted); + background: var(--v2-background-bg-layer-01); +} + +.terminal-grip { + position: absolute; + top: -3px; + left: 0; + right: 0; + height: 7px; + cursor: ns-resize; + z-index: 1; +} + +.terminal-grip:hover { + background: var(--v2-border-border-focus); +} + +.terminal-bar { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + min-height: 36px; + padding: 4px 8px 4px 12px; + border-bottom: 0.5px solid var(--v2-border-border-muted); +} + +.terminal-title { + font-weight: 560; + white-space: nowrap; +} + +.terminal-spacer { + flex: 1; + min-width: 8px; +} + +/* omp | shell: two buttons that read as one control */ +.terminal-kind { + display: inline-flex; + border: 0.5px solid var(--v2-border-border-base); + border-radius: 5px; + overflow: hidden; +} + +.terminal-kind button { + height: 24px; + padding: 0 10px; + color: var(--v2-text-text-muted); +} + +.terminal-kind button + button { + border-left: 0.5px solid var(--v2-border-border-base); +} + +.terminal-kind button:hover { + color: var(--v2-text-text-base); +} + +.terminal-kind button[aria-pressed="true"] { + background: var(--v2-background-bg-layer-03); + color: var(--v2-text-text-base); + font-weight: 530; +} + +.terminal-status { + font-size: 12px; + line-height: 18px; + color: var(--v2-text-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.terminal-status[data-phase="connected"] { + color: var(--v2-state-fg-success); +} + +.terminal-status[data-phase="error"], +.terminal-status[data-phase="disconnected"] { + color: var(--v2-state-fg-danger); +} + +.terminal-leave { + white-space: nowrap; +} + +.terminal-body { + position: relative; + flex: 1; + min-height: 0; + display: flex; +} + +.terminal-surface { + flex: 1; + min-width: 0; + min-height: 0; + padding: 6px 8px; + overflow: hidden; + background: var(--v2-background-bg-layer-01); +} + +.terminal-surface canvas { + display: block; +} + +/* what the terminal cannot say for itself: starting, failed, ended, dropped */ +.terminal-banner { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 16px; + text-align: center; + color: var(--v2-text-text-muted); + background: color-mix(in srgb, var(--v2-background-bg-layer-01) 88%, transparent); +} + +.terminal-banner p { + max-width: 60ch; + margin: 0; + color: var(--v2-text-text-base); + overflow-wrap: anywhere; +} + +.terminal-banner-error p { + color: var(--v2-state-fg-danger); +} + +.terminal-actions { + display: flex; + gap: 8px; +} + +.terminal-strip { + position: absolute; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + gap: 10px; + padding: 6px 12px; + border-top: 0.5px solid var(--v2-border-border-muted); + background: var(--v2-background-bg-layer-02); + color: var(--v2-text-text-base); +} + +.terminal-strip[data-phase="disconnected"] { + color: var(--v2-state-fg-danger); +} diff --git a/packages/flow/src/ui/terminal-dock.tsx b/packages/flow/src/ui/terminal-dock.tsx new file mode 100644 index 0000000000..eb79b4d6c4 --- /dev/null +++ b/packages/flow/src/ui/terminal-dock.tsx @@ -0,0 +1,389 @@ +import { Show, createSignal, onCleanup, onMount } from "solid-js" +import type { FitAddon as GhosttyFit, Ghostty, Terminal as GhosttyTerminal } from "ghostty-web" +import { PTY_ROUTES } from "../../shared/terminal" +import type { PtyKind, PtyTicket } from "../../shared/terminal" +import { IconClose } from "./icons" +import { decodeServerFrame, encodeResize, ptyHeaders, readProblem, socketUrl, ticketInit } from "./terminal-frames" +import { actionLabel, initialState, needsOmpSettings, reduce, statusText } from "./terminal-session" +import type { SessionEvent, SessionState } from "./terminal-session" +import "./terminal-dock.css" + +/** The bottom dock the way the activity drawer is one: a row of the app column, resizable by its grip. */ +const DEFAULT_HEIGHT = 0.35 +const MIN_HEIGHT = 0.15 +const MAX_HEIGHT = 0.7 + +/** Sessions outlive the dock: closing it detaches, opening it attaches again. */ +const remembered: Partial> = {} + +type Emulator = { Terminal: typeof GhosttyTerminal; FitAddon: typeof GhosttyFit; ghostty: Ghostty } + +let loading: Promise | undefined + +/** + * ghostty-web, once: a dynamic import keeps its ~400 KB of inlined wasm in a + * chunk nobody downloads until the dock opens. The promise is dropped on + * failure so a flaky first load can be retried. + */ +function loadEmulator(): Promise { + if (loading) return loading + loading = import("ghostty-web") + .then(async (mod) => ({ Terminal: mod.Terminal, FitAddon: mod.FitAddon, ghostty: await mod.Ghostty.load() })) + .catch((error) => { + loading = undefined + throw error + }) + return loading +} + +/** Any CSS colour (the `--v2-*` tokens are not all hex) as `#rrggbb`, via a 1x1 canvas. */ +function colour(name: string, fallback: string): string { + const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim() + const context = document.createElement("canvas").getContext("2d", { willReadFrequently: true }) + if (!value || !context) return fallback + context.fillStyle = "#000" + context.fillStyle = value + context.fillRect(0, 0, 1, 1) + const [r, g, b] = context.getImageData(0, 0, 1, 1).data + return `#${[r, g, b].map((channel) => channel.toString(16).padStart(2, "0")).join("")}` +} + +export type TerminalDockProps = { + open: boolean + onClose(): void + /** Which session to open first; the dock's own switch changes it afterwards. */ + kind?: PtyKind + /** Shown as a link when omp is missing or too old. */ + onOpenSettings?(): void +} + +export function TerminalDock(props: TerminalDockProps) { + return ( + + + + ) +} + +/** + * Keys: everything typed inside the dock goes to the shell, `Esc` included, and + * none of it reaches the window handlers (the activity drawer closes on `Esc`, + * the canvas undoes on Ctrl+Z). The one key the terminal does not receive is + * Ctrl+` — it leaves the terminal for the dock's own controls, and the header + * says so. Ctrl+Shift+C copies the selection; paste is the browser's. + * + * The emulator draws to a canvas, so a screen reader cannot read the grid: the + * `aria-live` line carries connection and exit state, and the region is named. + */ +function Dock(props: { kind: PtyKind; onClose(): void; onOpenSettings?(): void }) { + const [view, setView] = createSignal(initialState(props.kind)) + const [height, setHeight] = createSignal(DEFAULT_HEIGHT) + let current = view() + let dock!: HTMLElement + let surface!: HTMLDivElement + let kindSwitch!: HTMLDivElement + let term: GhosttyTerminal | undefined + let socket: WebSocket | undefined + let retry: number | undefined + let sizing: number | undefined + let attempt = 0 + let disposed = false + const cleanups: (() => void)[] = [] + + const dispatch = (event: SessionEvent) => { + current = reduce(current, event) + setView(current) + } + + function sendSize() { + if (!term || socket?.readyState !== WebSocket.OPEN) return + socket.send(encodeResize(term.cols, term.rows)) + } + + async function ensureTerminal(): Promise { + if (term) return term + const emulator = await loadEmulator() + if (disposed) throw new Error("closed") + const created = new emulator.Terminal({ + cursorBlink: true, + cursorStyle: "bar", + fontSize: 13, + fontFamily: getComputedStyle(document.documentElement).getPropertyValue("--v2-font-family-mono").trim() || "monospace", + scrollback: 10_000, + allowTransparency: false, + convertEol: false, + ghostty: emulator.ghostty, + theme: { + background: colour("--v2-background-bg-layer-01", "#191515"), + foreground: colour("--v2-text-text-base", "#d4d4d4"), + cursor: colour("--v2-text-text-base", "#d4d4d4"), + selectionBackground: colour("--v2-background-bg-accent", "#3b6fd4"), + }, + }) + const fit = new emulator.FitAddon() + created.loadAddon(fit) + created.attachCustomKeyEventHandler((event) => { + // `true` means "the dock handled it": the key is not sent to the pty. + if (event.ctrlKey && event.shiftKey && !event.metaKey && event.key.toLowerCase() === "c") { + const selection = created.getSelection() + if (selection && !document.execCommand("copy")) void navigator.clipboard?.writeText(selection) + return true + } + if (event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey && event.key === "`") { + created.blur() + created.textarea?.blur() + kindSwitch.querySelector("button")?.focus() + return true + } + return false + }) + created.open(surface) + fit.fit() + fit.observeResize() + cleanups.push(() => fit.dispose()) + + const onCopy = (event: ClipboardEvent) => { + const selection = created.getSelection() + if (!selection || !event.clipboardData) return + event.preventDefault() + event.clipboardData.setData("text/plain", selection) + } + const onPaste = (event: ClipboardEvent) => { + const text = event.clipboardData?.getData("text/plain") ?? "" + if (!text) return + event.preventDefault() + event.stopPropagation() + created.paste(text) + } + surface.addEventListener("copy", onCopy) + surface.addEventListener("paste", onPaste, true) + cleanups.push(() => { + surface.removeEventListener("copy", onCopy) + surface.removeEventListener("paste", onPaste, true) + }) + + const data = created.onData((text) => { + if (socket?.readyState === WebSocket.OPEN) socket.send(text) + }) + // The pty needs the new size once the user stops dragging, not on every pixel. + const resized = created.onResize(() => { + clearTimeout(sizing) + sizing = window.setTimeout(sendSize, 100) + }) + cleanups.push(() => { + data.dispose() + resized.dispose() + }) + term = created + return created + } + + function closeSocket() { + clearTimeout(retry) + const old = socket + socket = undefined + old?.close(1000) + } + + function scheduleRetry(generation: number) { + if (current.phase !== "disconnected" || current.retryInMs === undefined) return + retry = window.setTimeout(() => { + if (generation !== attempt) return + dispatch({ type: "retry" }) + void connect(generation, false) + }, current.retryInMs) + } + + /** Asks for a ticket (attach when the session is known, create otherwise), then opens the socket with it. */ + async function connect(generation: number, recreateIfGone: boolean) { + let terminal: GhosttyTerminal + try { + terminal = await ensureTerminal() + } catch (error) { + if (generation !== attempt || disposed) return + const reason = error instanceof Error ? error.message : "unknown error" + dispatch({ type: "failed", problem: { message: `the terminal emulator failed to load: ${reason}`, code: "emulator" } }) + return + } + if (generation !== attempt) return + + let granted: PtyTicket + try { + const response = await fetch( + PTY_ROUTES.ticket, + ticketInit(current.id === undefined ? { create: { kind: current.kind, cols: terminal.cols, rows: terminal.rows } } : { id: current.id }), + ) + if (generation !== attempt) return + if (!response.ok) { + const problem = await readProblem(response) + if (generation !== attempt) return + if (recreateIfGone && problem.status === 404 && problem.code === "gone") { + delete remembered[current.kind] + void begin(current.kind, true) + return + } + dispatch({ type: "failed", status: problem.status, problem: { message: problem.message, code: problem.code } }) + return + } + granted = (await response.json()) as PtyTicket + } catch { + if (generation !== attempt) return + dispatch({ type: "failed", network: true, problem: { message: "the host is not reachable" } }) + scheduleRetry(generation) + return + } + + dispatch({ type: "ticket", id: granted.id }) + remembered[current.kind] = granted.id + const opened = new WebSocket(socketUrl(window.location, granted, current.cursor)) + opened.binaryType = "arraybuffer" + socket = opened + opened.onopen = () => { + if (opened !== socket) return + dispatch({ type: "open" }) + sendSize() + term?.focus() + } + opened.onmessage = (event) => { + if (opened !== socket) return + const frame = decodeServerFrame(event.data) + if (frame.kind === "output") { + term?.write(frame.text) + dispatch({ type: "output", length: frame.text.length }) + return + } + if (frame.kind !== "control") return + dispatch("cursor" in frame.control ? { type: "cursor", cursor: frame.control.cursor } : { type: "exit", exit: frame.control.exit }) + } + opened.onclose = (event) => { + if (opened !== socket) return + socket = undefined + dispatch({ type: "closed", code: event.code }) + scheduleRetry(generation) + } + } + + /** A new connection attempt: a remembered session for `kind` when there is one, else a fresh one. */ + async function begin(kind: PtyKind, fresh = false) { + const generation = ++attempt + closeSocket() + const id = fresh ? undefined : remembered[kind] + dispatch({ type: "begin", kind, id }) + // A new session starts on a clean screen; a reconnect (no `begin`) never clears. + term?.reset() + await connect(generation, id !== undefined) + } + + function restart() { + const old = current.id + if (old !== undefined) { + delete remembered[current.kind] + void fetch(PTY_ROUTES.session(old), { method: "DELETE", headers: ptyHeaders }).catch(() => undefined) + } + void begin(current.kind, true) + } + + function act() { + const action = actionLabel(current) + if (action === "Restart") restart() + else if (action === "Reconnect") { + attempt += 1 + closeSocket() + dispatch({ type: "retry", manual: true }) + void connect(attempt, false) + } else if (action === "Try again") void begin(current.kind, true) + } + + function startResize(event: PointerEvent) { + event.preventDefault() + const move = (moved: PointerEvent) => + setHeight(Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, 1 - moved.clientY / window.innerHeight))) + const up = () => { + window.removeEventListener("pointermove", move) + window.removeEventListener("pointerup", up) + } + window.addEventListener("pointermove", move) + window.addEventListener("pointerup", up) + } + + onMount(() => { + // Native, not Solid's delegated handler: this has to stop the event before it + // reaches `window`, where the activity drawer and the canvas listen. + const contain = (event: KeyboardEvent) => event.stopPropagation() + dock.addEventListener("keydown", contain) + cleanups.push(() => dock.removeEventListener("keydown", contain)) + void begin(props.kind) + }) + + onCleanup(() => { + disposed = true + attempt += 1 + closeSocket() + clearTimeout(sizing) + for (const cleanup of cleanups.splice(0)) cleanup() + term?.dispose() + term = undefined + }) + + const state = view + const strip = () => state().phase === "exited" || state().phase === "disconnected" + + return ( +
    +
    + +
    + Terminal +
    + + +
    + + {statusText(state())} + + + Ctrl+` leaves the terminal + +
    + +
    +
    + + +
    {statusText(state())}
    +
    + + + + + + +
    + {statusText(state())} + +
    +
    +
    +
    + ) +} diff --git a/packages/flow/src/ui/terminal-frames.test.ts b/packages/flow/src/ui/terminal-frames.test.ts new file mode 100644 index 0000000000..9246034f63 --- /dev/null +++ b/packages/flow/src/ui/terminal-frames.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test" +import { PTY_HEADER, PTY_LIMITS } from "../../shared/terminal" +import { decodeServerFrame, encodeResize, readProblem, socketUrl, ticketInit } from "./terminal-frames" + +const control = (value: unknown) => Uint8Array.from([0, ...new TextEncoder().encode(JSON.stringify(value))]) + +describe("decodeServerFrame", () => { + test("text is output, verbatim", () => { + expect(decodeServerFrame("ls\r\n\u001b[31mred")).toEqual({ kind: "output", text: "ls\r\n\u001b[31mred" }) + expect(decodeServerFrame("")).toEqual({ kind: "output", text: "" }) + }) + + test("binary 0x00 + JSON is a cursor or an exit, from an ArrayBuffer or a view", () => { + expect(decodeServerFrame(control({ cursor: 1234 }).buffer)).toEqual({ kind: "control", control: { cursor: 1234 } }) + expect(decodeServerFrame(control({ exit: { code: 3, signal: null } }))).toEqual({ + kind: "control", + control: { exit: { code: 3, signal: null } }, + }) + expect(decodeServerFrame(control({ exit: { code: null, signal: "SIGHUP" } }))).toEqual({ + kind: "control", + control: { exit: { code: null, signal: "SIGHUP" } }, + }) + // A view into a larger buffer must be read at its own offset. + const padded = new Uint8Array(40) + padded.set(control({ cursor: 7 }), 5) + expect(decodeServerFrame(padded.subarray(5, 5 + control({ cursor: 7 }).length))).toEqual({ kind: "control", control: { cursor: 7 } }) + }) + + test.each([ + ["a binary frame that is not a control", Uint8Array.from([1, 2, 3])], + ["an empty binary frame", new Uint8Array(0)], + ["bad JSON", Uint8Array.from([0, ...new TextEncoder().encode("{nope")])], + ["JSON null", control(null)], + ["an array", control([1])], + ["an unknown control", control({ hello: "world" })], + ["a non-numeric cursor", control({ cursor: "9" })], + ["a non-finite cursor", Uint8Array.from([0, ...new TextEncoder().encode('{"cursor":1e999}')])], + ["a malformed exit", control({ exit: { code: "3", signal: null } })], + ["an exit without a signal field", control({ exit: { code: 1 } })], + ["not a frame at all", 42], + ["undefined", undefined], + ])("ignores %s", (_name, data) => { + expect(decodeServerFrame(data)).toEqual({ kind: "ignored" }) + }) +}) + +describe("encodeResize", () => { + test("0x00 + JSON, the shape the host decodes", () => { + const bytes = encodeResize(120, 40) + expect(bytes[0]).toBe(0) + expect(JSON.parse(new TextDecoder().decode(bytes.subarray(1)))).toEqual({ t: "resize", cols: 120, rows: 40 }) + }) + + test("clamps to the host's limits and truncates fractions", () => { + const decode = (cols: number, rows: number) => JSON.parse(new TextDecoder().decode(encodeResize(cols, rows).subarray(1))) + expect(decode(5000, 5000)).toEqual({ t: "resize", cols: PTY_LIMITS.maxCols, rows: PTY_LIMITS.maxRows }) + expect(decode(1, 1)).toEqual({ t: "resize", cols: PTY_LIMITS.minCols, rows: PTY_LIMITS.minRows }) + expect(decode(80.9, 24.9)).toEqual({ t: "resize", cols: 80, rows: 24 }) + expect(decode(Number.NaN, Number.NaN)).toEqual({ t: "resize", cols: PTY_LIMITS.minCols, rows: PTY_LIMITS.minRows }) + }) +}) + +describe("socketUrl", () => { + const granted = { wsPath: "/flow/pty/abc-123/ws", ticket: "t.o/k+en=" } + + test("ws on http, wss on https, ticket encoded", () => { + expect(socketUrl({ protocol: "http:", host: "127.0.0.1:5173" }, granted, 0)).toBe( + "ws://127.0.0.1:5173/flow/pty/abc-123/ws?ticket=t.o%2Fk%2Ben%3D", + ) + expect(socketUrl({ protocol: "https:", host: "localhost:4097" }, granted, 0)).toStartWith("wss://localhost:4097/") + }) + + test("a cursor resumes; zero replays everything", () => { + const page = { protocol: "http:", host: "localhost:5173" } + expect(socketUrl(page, granted, 0)).not.toContain("cursor") + expect(socketUrl(page, granted, 4096)).toEndWith("&cursor=4096") + expect(socketUrl(page, granted, 12.7)).toEndWith("&cursor=12") + }) +}) + +describe("ticketInit", () => { + test("POST with the custom header and a JSON body", () => { + const init = ticketInit({ create: { kind: "omp", cols: 100, rows: 30 } }) + expect(init.method).toBe("POST") + expect(init.headers).toMatchObject({ [PTY_HEADER]: "1", "content-type": "application/json" }) + expect(JSON.parse(init.body as string)).toEqual({ create: { kind: "omp", cols: 100, rows: 30 } }) + expect(JSON.parse(ticketInit({ id: "abc" }).body as string)).toEqual({ id: "abc" }) + }) +}) + +describe("readProblem", () => { + test("reads the host's {error, code}", async () => { + const response = Response.json({ error: "omp is not installed", code: "omp-unavailable" }, { status: 409 }) + expect(await readProblem(response)).toEqual({ status: 409, message: "omp is not installed", code: "omp-unavailable" }) + }) + + test("a body that is not that shape still gives an honest message", async () => { + expect(await readProblem(new Response("", { status: 502 }))).toEqual({ status: 502, message: "the host answered 502", code: undefined }) + expect(await readProblem(Response.json({ error: "", code: 5 }, { status: 500 }))).toEqual({ + status: 500, + message: "the host answered 500", + code: undefined, + }) + }) +}) diff --git a/packages/flow/src/ui/terminal-frames.ts b/packages/flow/src/ui/terminal-frames.ts new file mode 100644 index 0000000000..6733f8788e --- /dev/null +++ b/packages/flow/src/ui/terminal-frames.ts @@ -0,0 +1,86 @@ +import { PTY_CONTROL, PTY_HEADER, clampSize } from "../../shared/terminal" +import type { PtyClientControl, PtyServerControl, PtyTicket, PtyTicketRequest } from "../../shared/terminal" + +/** + * The browser's half of the terminal wire format (the host's half is + * `lib/terminal/policy.ts`). Pure, so `bun test` covers it without a DOM. + */ + +export type ServerFrame = + | { kind: "output"; text: string } + | { kind: "control"; control: PtyServerControl } + | { kind: "ignored" } + +const decoder = new TextDecoder() + +function isExit(value: unknown): value is { code: number | null; signal: string | null } { + if (typeof value !== "object" || value === null) return false + const exit = value as { code?: unknown; signal?: unknown } + return (exit.code === null || typeof exit.code === "number") && (exit.signal === null || typeof exit.signal === "string") +} + +/** A WebSocket message: text is terminal output, binary `0x00` + JSON is a control frame, anything else is ignored. */ +export function decodeServerFrame(data: unknown): ServerFrame { + if (typeof data === "string") return { kind: "output", text: data } + const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : ArrayBuffer.isView(data) ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) : undefined + if (!bytes || bytes[0] !== PTY_CONTROL) return { kind: "ignored" } + let parsed: unknown + try { + parsed = JSON.parse(decoder.decode(bytes.subarray(1))) + } catch { + return { kind: "ignored" } + } + if (typeof parsed !== "object" || parsed === null) return { kind: "ignored" } + const frame = parsed as { cursor?: unknown; exit?: unknown } + if (typeof frame.cursor === "number" && Number.isFinite(frame.cursor)) return { kind: "control", control: { cursor: frame.cursor } } + if (isExit(frame.exit)) return { kind: "control", control: { exit: frame.exit } } + return { kind: "ignored" } +} + +/** `0x00` + `{t:"resize"}`, clamped the way the host will clamp it anyway. */ +export function encodeResize(cols: number, rows: number): Uint8Array { + const size = clampSize(cols, rows) + const control: PtyClientControl = { t: "resize", cols: size.cols, rows: size.rows } + const json = new TextEncoder().encode(JSON.stringify(control)) + const out = new Uint8Array(json.length + 1) + out[0] = PTY_CONTROL + out.set(json, 1) + return out +} + +/** + * Where to open the socket. Cursor 0 omits `cursor` so a brand-new attach + * replays the whole retained scrollback (output printed before the socket + * opened); later reconnects resume exactly where the client stopped. + */ +export function socketUrl(page: { protocol: string; host: string }, granted: Pick, cursor: number): string { + const scheme = page.protocol === "https:" ? "wss:" : "ws:" + const query = `ticket=${encodeURIComponent(granted.ticket)}${cursor > 0 ? `&cursor=${Math.trunc(cursor)}` : ""}` + return `${scheme}//${page.host}${granted.wsPath}?${query}` +} + +/** The custom header is what makes a cross-origin page unable to call these routes at all. */ +export const ptyHeaders = { [PTY_HEADER]: "1" } + +export function ticketInit(request: PtyTicketRequest): RequestInit { + return { + method: "POST", + headers: { ...ptyHeaders, "content-type": "application/json" }, + body: JSON.stringify(request), + } +} + +export type FetchProblem = { message: string; code?: string; status: number } + +/** The `{error, code?}` body every host module answers with; anything else becomes a generic message. */ +export async function readProblem(response: Response): Promise { + let body: { error?: unknown; code?: unknown } = {} + try { + body = (await response.json()) as typeof body + } catch {} + return { + status: response.status, + message: typeof body.error === "string" && body.error ? body.error : `the host answered ${response.status}`, + code: typeof body.code === "string" ? body.code : undefined, + } +} diff --git a/packages/flow/src/ui/terminal-session.test.ts b/packages/flow/src/ui/terminal-session.test.ts new file mode 100644 index 0000000000..e335713a1b --- /dev/null +++ b/packages/flow/src/ui/terminal-session.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test" +import { PTY_CLOSE } from "../../shared/terminal" +import { MAX_ATTEMPTS, actionLabel, backoffMs, initialState, needsOmpSettings, reduce, statusText } from "./terminal-session" +import type { SessionEvent, SessionState } from "./terminal-session" + +const run = (events: SessionEvent[], from: SessionState = initialState("omp")) => events.reduce(reduce, from) + +const connected = () => run([{ type: "begin", kind: "omp" }, { type: "ticket", id: "s1" }, { type: "open" }]) + +describe("the happy path", () => { + test("idle -> loading (create) -> ticket -> open -> output advances the cursor", () => { + expect(initialState("shell")).toMatchObject({ phase: "idle", kind: "shell", cursor: 0 }) + const loading = run([{ type: "begin", kind: "omp" }]) + expect(loading).toMatchObject({ phase: "loading", id: undefined, cursor: 0, attempt: 0 }) + expect(statusText(loading)).toBe("Starting terminal…") + + const live = run([{ type: "ticket", id: "s1" }, { type: "open" }, { type: "output", length: 10 }, { type: "output", length: 5 }], loading) + expect(live).toMatchObject({ phase: "connected", id: "s1", cursor: 15 }) + expect(statusText(live)).toBe("Connected") + }) + + test("the server's cursor frame replaces the local count", () => { + const live = run([{ type: "output", length: 100 }, { type: "cursor", cursor: 96 }], connected()) + expect(live.cursor).toBe(96) + }) +}) + +describe("reconnect", () => { + test("a dropped socket retries with backoff and resumes from the cursor, then the attempt count resets", () => { + let state = run([{ type: "output", length: 500 }], connected()) + const delays: (number | undefined)[] = [] + for (let i = 0; i < 4; i++) { + state = reduce(state, { type: "closed", code: 1006 }) + expect(state).toMatchObject({ phase: "disconnected", id: "s1", cursor: 500 }) + delays.push(state.retryInMs) + state = reduce(state, { type: "retry" }) + expect(state.phase).toBe("loading") + expect(statusText(state)).toBe("Reconnecting…") + } + expect(delays).toEqual([250, 500, 1000, 2000]) + + state = run([{ type: "ticket", id: "s1" }, { type: "open" }], state) + expect(state).toMatchObject({ phase: "connected", attempt: 0, cursor: 500 }) + }) + + test("backoff caps at 8 s and automatic retry stops after MAX_ATTEMPTS, leaving a manual Reconnect", () => { + expect([0, 1, 2, 3, 4, 5, 6, 10].map(backoffMs)).toEqual([250, 500, 1000, 2000, 4000, 8000, 8000, 8000]) + let state = connected() + for (let i = 0; i < MAX_ATTEMPTS; i++) state = reduce(reduce(state, { type: "closed", code: 1006 }), { type: "retry" }) + expect(state.retryInMs).toBeUndefined() + state = reduce(state, { type: "closed", code: 1006 }) + expect(state).toMatchObject({ phase: "disconnected", retryInMs: undefined }) + expect(statusText(state)).toBe("Disconnected") + expect(actionLabel(state)).toBe("Reconnect") + + state = reduce(state, { type: "retry", manual: true }) + expect(state).toMatchObject({ phase: "loading", attempt: 0, id: "s1" }) + }) + + test("a slow-consumer close (1013) is just another reconnect", () => { + expect(reduce(connected(), { type: "closed", code: 1013 })).toMatchObject({ phase: "disconnected", retryInMs: 250 }) + }) + + test("the status line counts the retry in whole seconds", () => { + const state = run([{ type: "closed", code: 1006 }, { type: "retry" }, { type: "closed", code: 1006 }, { type: "retry" }, { type: "closed", code: 1006 }], connected()) + expect(statusText(state)).toBe("Disconnected, retrying in 1s") + expect(statusText(reduce(connected(), { type: "closed", code: 1006 }))).toBe("Disconnected, retrying in 1s") + }) + + test("a network failure asking for a ticket on a known session retries; on a new one it is an error", () => { + const retrying = reduce(connected(), { type: "failed", problem: { message: "offline" }, network: true }) + expect(retrying).toMatchObject({ phase: "disconnected", retryInMs: 250 }) + + const creating = run([{ type: "begin", kind: "omp" }, { type: "failed", problem: { message: "offline" }, network: true }]) + expect(creating).toMatchObject({ phase: "error", error: { message: "offline" } }) + expect(actionLabel(creating)).toBe("Try again") + }) + + test("a close while already disconnected, exited or failed changes nothing", () => { + const down = reduce(connected(), { type: "closed", code: 1006 }) + expect(reduce(down, { type: "closed", code: 1006 })).toBe(down) + const exited = run([{ type: "exit", exit: { code: 0, signal: null } }], connected()) + expect(reduce(exited, { type: "closed", code: PTY_CLOSE.normal })).toBe(exited) + const failed = run([{ type: "begin", kind: "omp" }, { type: "failed", problem: { message: "no" }, status: 409 }]) + expect(reduce(failed, { type: "closed", code: 1006 })).toBe(failed) + }) +}) + +describe("exit", () => { + test("an exit frame ends the session, names the code, and offers Restart", () => { + const state = run([{ type: "exit", exit: { code: 1, signal: null } }, { type: "closed", code: PTY_CLOSE.normal }], connected()) + expect(state).toMatchObject({ phase: "exited", exit: { code: 1, signal: null } }) + expect(statusText(state)).toBe("Exited 1") + expect(actionLabel(state)).toBe("Restart") + }) + + test("a signal wins over the code in the wording; no info at all still reads sensibly", () => { + expect(statusText(run([{ type: "exit", exit: { code: 0, signal: "SIGHUP" } }], connected()))).toBe("Exited (SIGHUP)") + expect(statusText(run([{ type: "exit", exit: { code: null, signal: null } }], connected()))).toBe("Exited") + expect(statusText(reduce(connected(), { type: "closed", code: PTY_CLOSE.gone }))).toBe("Session ended") + }) + + test("restart is a begin without an id: a new session, cursor and exit cleared", () => { + const exited = run([{ type: "output", length: 900 }, { type: "exit", exit: { code: 2, signal: null } }], connected()) + const again = reduce(exited, { type: "begin", kind: "omp" }) + expect(again).toEqual({ phase: "loading", kind: "omp", id: undefined, cursor: 0, attempt: 0 }) + }) + + test("switching kind starts clean; resuming the same id keeps the cursor, another id does not", () => { + const live = run([{ type: "output", length: 40 }], connected()) + expect(reduce(live, { type: "begin", kind: "shell" })).toMatchObject({ kind: "shell", id: undefined, cursor: 0 }) + expect(reduce(live, { type: "begin", kind: "omp", id: "s1" }).cursor).toBe(40) + expect(reduce(live, { type: "begin", kind: "omp", id: "other" }).cursor).toBe(0) + }) + + test("a session the host forgot is over, not unreachable", () => { + const state = reduce(connected(), { type: "failed", status: 404, problem: { message: "no such terminal", code: "gone" } }) + expect(state).toMatchObject({ phase: "exited", exit: undefined }) + expect(statusText(state)).toBe("Session ended") + }) +}) + +describe("errors", () => { + test("a spawn failure carries the server's message verbatim", () => { + const state = run([ + { type: "begin", kind: "omp" }, + { type: "failed", status: 500, problem: { message: "could not start the terminal: File not found: omp", code: "spawn-failed" } }, + ]) + expect(state.phase).toBe("error") + expect(statusText(state)).toBe("Error: could not start the terminal: File not found: omp") + expect(needsOmpSettings(state)).toBe(false) + }) + + test("omp missing links to settings and nothing else does", () => { + const state = run([ + { type: "begin", kind: "omp" }, + { type: "failed", status: 409, problem: { message: "omp is not installed", code: "omp-unavailable" } }, + ]) + expect(needsOmpSettings(state)).toBe(true) + expect(statusText(state)).toBe("Error: omp is not installed") + expect(needsOmpSettings(connected())).toBe(false) + expect(needsOmpSettings(run([{ type: "begin", kind: "omp" }, { type: "failed", status: 403, problem: { message: "no", code: "bad-origin" } }]))).toBe(false) + }) + + test("try again from an error is a fresh begin", () => { + const failed = run([{ type: "begin", kind: "omp" }, { type: "failed", problem: { message: "x" }, status: 500 }]) + const again = reduce(failed, { type: "begin", kind: "omp" }) + expect(again.phase).toBe("loading") + expect(again.error).toBeUndefined() + }) + + test("idle, loading and connected offer no button", () => { + expect(actionLabel(initialState("omp"))).toBeUndefined() + expect(actionLabel(reduce(initialState("omp"), { type: "begin", kind: "omp" }))).toBeUndefined() + expect(actionLabel(connected())).toBeUndefined() + expect(statusText(initialState("omp"))).toBe("Terminal closed") + }) +}) diff --git a/packages/flow/src/ui/terminal-session.ts b/packages/flow/src/ui/terminal-session.ts new file mode 100644 index 0000000000..29e591de35 --- /dev/null +++ b/packages/flow/src/ui/terminal-session.ts @@ -0,0 +1,139 @@ +import { PTY_CLOSE } from "../../shared/terminal" +import type { PtyKind } from "../../shared/terminal" + +/** + * The dock's connection logic as a pure reducer, so reconnect, resume and the + * wording of every state are tested without a DOM or a socket. The component + * only turns I/O results into events and renders the state. + */ + +export type SessionPhase = "idle" | "loading" | "connected" | "exited" | "disconnected" | "error" + +export type SessionProblem = { message: string; code?: string } + +export type SessionState = { + phase: SessionPhase + kind: PtyKind + /** The host's session id once a ticket was granted; kept across reconnects. */ + id?: string + /** UTF-16 units of output received: where a reconnect resumes. */ + cursor: number + /** Consecutive failed (re)connects; 0 once a socket opens. */ + attempt: number + exit?: { code: number | null; signal: string | null } + error?: SessionProblem + /** Delay before the next automatic reconnect; undefined when automatic retry has stopped. */ + retryInMs?: number +} + +export type SessionEvent = + /** Start connecting. With `id`: resume that session. Without: create a new one (restart, or a different kind). */ + | { type: "begin"; kind: PtyKind; id?: string } + | { type: "ticket"; id: string } + | { type: "open" } + | { type: "output"; length: number } + | { type: "cursor"; cursor: number } + | { type: "exit"; exit: { code: number | null; signal: string | null } } + | { type: "closed"; code: number } + /** A ticket request failed: `network` when the host could not be reached at all. */ + | { type: "failed"; problem: SessionProblem; status?: number; network?: boolean } + /** An automatic retry whose timer fired, or the user's Reconnect. */ + | { type: "retry"; manual?: boolean } + +/** After this many failed reconnects in a row the dock stops retrying and offers a button. */ +export const MAX_ATTEMPTS = 8 +const BACKOFF_BASE_MS = 250 +const BACKOFF_CAP_MS = 8000 + +export function initialState(kind: PtyKind): SessionState { + return { phase: "idle", kind, cursor: 0, attempt: 0 } +} + +/** 250 ms, 500, 1 s, 2 s, 4 s, then 8 s. */ +export function backoffMs(attempt: number): number { + return Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** Math.max(0, attempt)) +} + +function disconnected(state: SessionState): SessionState { + const attempt = state.attempt + 1 + return { + ...state, + phase: "disconnected", + attempt, + error: undefined, + retryInMs: attempt > MAX_ATTEMPTS ? undefined : backoffMs(attempt - 1), + } +} + +export function reduce(state: SessionState, event: SessionEvent): SessionState { + switch (event.type) { + case "begin": + return { + phase: "loading", + kind: event.kind, + id: event.id, + cursor: event.id !== undefined && event.id === state.id ? state.cursor : 0, + attempt: 0, + } + case "ticket": + return { ...state, id: event.id } + case "open": + return { ...state, phase: "connected", attempt: 0, error: undefined, retryInMs: undefined } + case "output": + return { ...state, cursor: state.cursor + event.length } + case "cursor": + return { ...state, cursor: event.cursor } + case "exit": + return { ...state, phase: "exited", exit: event.exit, error: undefined, retryInMs: undefined } + case "closed": + // The host closes 1000 only after an exit frame, so a 1000 with the phase + // still live means the exit frame was lost: reconnecting reports it. + if (state.phase === "exited" || state.phase === "error" || state.phase === "idle" || state.phase === "disconnected") return state + if (event.code === PTY_CLOSE.gone) return { ...state, phase: "exited", exit: undefined, retryInMs: undefined } + return disconnected(state) + case "failed": + // A session the host no longer knows is over, not unreachable. + if (event.status === 404 && event.problem.code === "gone") return { ...state, phase: "exited", exit: undefined, retryInMs: undefined } + if (event.network && state.id !== undefined) return disconnected(state) + return { ...state, phase: "error", error: event.problem, retryInMs: undefined } + case "retry": + return { ...state, phase: "loading", attempt: event.manual ? 0 : state.attempt, retryInMs: undefined } + } +} + +/** The button the current state offers, if any. */ +export function actionLabel(state: SessionState): "Restart" | "Reconnect" | "Try again" | undefined { + if (state.phase === "exited") return "Restart" + if (state.phase === "disconnected") return "Reconnect" + if (state.phase === "error") return "Try again" + return undefined +} + +/** The text mirrored into the `aria-live` status line. */ +export function statusText(state: SessionState): string { + switch (state.phase) { + case "idle": + return "Terminal closed" + case "loading": + return state.id === undefined ? "Starting terminal…" : state.attempt > 0 ? "Reconnecting…" : "Connecting…" + case "connected": + return "Connected" + case "exited": { + const exit = state.exit + if (!exit) return "Session ended" + if (exit.signal) return `Exited (${exit.signal})` + return exit.code === null ? "Exited" : `Exited ${exit.code}` + } + case "disconnected": + return state.retryInMs === undefined + ? "Disconnected" + : `Disconnected, retrying in ${Math.max(1, Math.ceil(state.retryInMs / 1000))}s` + case "error": + return `Error: ${state.error?.message ?? "the terminal could not start"}` + } +} + +/** The one failure the dock links to settings for: omp is not installed or not new enough. */ +export function needsOmpSettings(state: SessionState): boolean { + return state.phase === "error" && state.error?.code === "omp-unavailable" +} diff --git a/packages/flow/src/ui/tool-help.test.ts b/packages/flow/src/ui/tool-help.test.ts index c86a8a788b..92dccad010 100644 --- a/packages/flow/src/ui/tool-help.test.ts +++ b/packages/flow/src/ui/tool-help.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import { TOOLS, TOOL_HELP } from "../server/store" +import { TOOLS } from "../server/card-spec" +import { TOOL_HELP } from "./tool-help" describe("TOOL_HELP", () => { test("has a non-empty entry for every tool the inspector renders", () => { diff --git a/packages/flow/src/ui/tool-help.ts b/packages/flow/src/ui/tool-help.ts new file mode 100644 index 0000000000..2eed06199f --- /dev/null +++ b/packages/flow/src/ui/tool-help.ts @@ -0,0 +1,19 @@ +/** + * Plain-language descriptions for the tool checkboxes, the highest-value + * tooltips: these boxes decide whether a node can write, edit or run commands, + * the source of the real-file-write hazard. Keyed by the same names as + * `TOOLS` (`../server/card-spec`), so `tool-help.test.ts` can assert full + * coverage. `edit` covers write and patch — they collapse onto one toggle. + */ +export const TOOL_HELP: Record = { + read: "Read files in the project", + grep: "Search file contents", + glob: "Find files by name pattern", + edit: "Create, modify, and delete files", + bash: "Run shell commands", + webfetch: "Fetch a web page by URL", + websearch: "Search the web", + todowrite: "Keep a running task list while it works", + skill: "Load a packaged skill's instructions", + question: "Pause and ask you a question", +} diff --git a/packages/flow/src/ui/uplift-dialog.tsx b/packages/flow/src/ui/uplift-dialog.tsx new file mode 100644 index 0000000000..7911b1af1e --- /dev/null +++ b/packages/flow/src/ui/uplift-dialog.tsx @@ -0,0 +1,118 @@ +import { Show, createUniqueId, onCleanup, onMount } from "solid-js" +import type { PendingUpliftFailure, UpliftChoice } from "../state" +import { upliftFailureView, wrapFocus } from "./card-stages-model" +import "./card-stages.css" +import { IconAlert } from "./icons" + +/** + * A card whose task could not be uplifted, waiting on a person. + * + * Nothing has been sent to the agent: the card is parked until one of three + * things happens — run it with the original prompt, try the uplift again, or + * cancel the card. Like the question dialog this is a modal, one failure at a + * time, but it is a real `alertdialog`: focus moves in (to the safe, repeatable + * choice), Tab stays inside, Esc cancels the card, and focus goes back to where + * it was when the dialog closes. The backdrop is not a dismiss target; a stray + * click must not decide for a card. + * + * Mount it keyed on the first pending failure so each failure gets a fresh + * dialog and a fresh focus restore. + */ +export function UpliftFailureDialog(props: { + failure: PendingUpliftFailure + /** Failures queued behind this one. */ + waiting?: number + onDecide: (requestID: string, choice: UpliftChoice) => void +}) { + const id = createUniqueId() + const titleId = `uplift-failure-title-${id}` + const bodyId = `uplift-failure-body-${id}` + const view = () => upliftFailureView(props.failure, props.waiting ?? 0) + let dialog!: HTMLElement + let retry!: HTMLButtonElement + const opener = document.activeElement instanceof HTMLElement ? document.activeElement : undefined + + const decide = (choice: UpliftChoice) => props.onDecide(props.failure.requestID, choice) + + // Capture phase on the window, so Esc cancels this card instead of also + // closing the activity drawer, and Tab cannot leave for the page behind. + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault() + event.stopPropagation() + return decide("cancel") + } + if (event.key !== "Tab") return + const controls = [...dialog.querySelectorAll("button:not([disabled])")] + const target = controls[wrapFocus(controls.indexOf(document.activeElement as HTMLElement), controls.length, event.shiftKey)] + if (!target) return + event.preventDefault() + target.focus() + } + + onMount(() => { + retry.focus() + window.addEventListener("keydown", onKeyDown, true) + }) + + onCleanup(() => { + window.removeEventListener("keydown", onKeyDown, true) + if (opener?.isConnected) opener.focus() + }) + + return ( +
    +
    +
    + +

    {view().title}

    +
    + +
    +

    {view().reason}

    +

    + nothing was sent to the agent{view().elapsed ? ` · ${view().elapsed}` : ""} +

    + +

    {view().queue}

    +
    +
    + +
    + + + +
    +
    +
    + ) +} diff --git a/packages/flow/src/ui/walkthrough.test.ts b/packages/flow/src/ui/walkthrough.test.ts index 686809e94a..151ac3560d 100644 --- a/packages/flow/src/ui/walkthrough.test.ts +++ b/packages/flow/src/ui/walkthrough.test.ts @@ -1,25 +1,27 @@ import { describe, expect, test } from "bun:test" import { walkthroughState } from "./walkthrough" -const base = { engineReachable: true, unlockedProviders: 0, nodes: 0, edges: 0, dismissed: false } +const base = { hostReachable: true, ompReady: true, modelCount: 0, nodes: 0, edges: 0, dismissed: false } describe("walkthroughState", () => { test("a fresh empty canvas is visible with nothing done", () => { - const result = walkthroughState({ ...base, engineReachable: false }) + const result = walkthroughState({ ...base, hostReachable: false, ompReady: false }) expect(result.visible).toBe(true) - expect(result.steps).toEqual({ engine: false, provider: false, node: false, connect: false }) + expect(result.steps).toEqual({ omp: false, models: false, node: false, connect: false }) }) - test("the engine step ticks once the server answers", () => { - expect(walkthroughState(base).steps.engine).toBe(true) - expect(walkthroughState({ ...base, engineReachable: false }).steps.engine).toBe(false) + test("the omp step needs a reachable host and a ready omp", () => { + expect(walkthroughState(base).steps.omp).toBe(true) + expect(walkthroughState({ ...base, hostReachable: false }).steps.omp).toBe(false) + expect(walkthroughState({ ...base, ompReady: false }).steps.omp).toBe(false) }) - test("an unlocked provider ticks step one", () => { - expect(walkthroughState({ ...base, unlockedProviders: 1 }).steps.provider).toBe(true) + test("a listed model ticks the models step", () => { + expect(walkthroughState({ ...base, modelCount: 1 }).steps.models).toBe(true) + expect(walkthroughState(base).steps.models).toBe(false) }) - test("a node hides the overlay and ticks step two", () => { + test("a node hides the overlay and ticks the node step", () => { const result = walkthroughState({ ...base, nodes: 1 }) expect(result.visible).toBe(false) expect(result.steps.node).toBe(true) diff --git a/packages/flow/src/ui/walkthrough.tsx b/packages/flow/src/ui/walkthrough.tsx index 050a59c803..ae3026f66f 100644 --- a/packages/flow/src/ui/walkthrough.tsx +++ b/packages/flow/src/ui/walkthrough.tsx @@ -19,8 +19,9 @@ const KEY = "openflow.walkthroughDone.v1" * live counts and the stored `dismissed` flag in. */ export function walkthroughState(input: { - engineReachable: boolean - unlockedProviders: number + hostReachable: boolean + ompReady: boolean + modelCount: number nodes: number edges: number dismissed: boolean @@ -29,9 +30,10 @@ export function walkthroughState(input: { visible: input.nodes === 0 && !input.dismissed, steps: { // First, because nothing else in this list can succeed without it: with - // the engine down there are no providers to connect and no models to run. - engine: input.engineReachable, - provider: input.unlockedProviders > 0, + // the host down or omp missing there are no models to list and nothing to + // run a card on. + omp: input.hostReachable && input.ompReady, + models: input.modelCount > 0, node: input.nodes > 0, // A single node needs no edge to be "connected"; a bigger graph does. connect: input.nodes > 0 && (input.edges > 0 || input.nodes === 1), @@ -48,16 +50,18 @@ function readDismissed() { } export function Walkthrough(props: { - unlockedProviders: number - suggestedFree?: string - onOpenProviders: () => void - onUseFree?: () => void + ompReady: boolean + ompMessage?: string + modelCount: number + onOpenOmp: () => void + onOpenTerminal: () => void }) { const [dismissed, setDismissed] = createSignal(readDismissed()) const view = () => walkthroughState({ - engineReachable: state.engineReachable, - unlockedProviders: props.unlockedProviders, + hostReachable: state.hostReachable, + ompReady: props.ompReady, + modelCount: props.modelCount, nodes: state.pipeline.nodes.length, edges: state.pipeline.edges.length, dismissed: dismissed(), @@ -84,51 +88,53 @@ export function Walkthrough(props: {
      -
    1. +
    2. - +
      - Start the engine + Check Omp - `opencode serve` is not answering — run `bun openflow.ts` from the repo root, then reload + {state.hostReachable + ? (props.ompMessage ?? "Omp is not ready — open its settings to see why") + : "the OpenFlow host is not answering — run `bun openflow.ts` from the repo root, then reload"} } > - `opencode serve` is answering — every card runs a session on it + {props.ompMessage ?? "Omp is ready — every card runs its own Omp session"} +
      + + +
    3. -
    4. +
    5. - +
      - Add a provider key + Have a model to run connect a provider so its models can run} + when={view().steps.models} + fallback={Omp lists no models yet — log in to a provider from the terminal} > - or run a free model, no key needed + + {props.modelCount} {props.modelCount === 1 ? "model" : "models"} available + -
      - - - - -
    6. From e0893df29232dc560e48c078dd42c2b8c4bd13aa Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:29:37 +0000 Subject: [PATCH 08/49] refactor(flow): remove the opencode engine from the host and launchers Mount the omp host in both hosts, retarget MCP and skills to the omp profile, drop the opencode proxy, supervisor, key import, repackage, zen and dispatch MCP server and the sdk dependency. Refs SPE-16066 SPE-16068 --- .claude/launch.json | 6 - bun.lock | 7 +- openflow.ps1 | 28 +- openflow.sh | 28 +- openflow.ts | 70 +- packages/flow/lib/cli-auth.test.ts | 107 --- packages/flow/lib/cli-auth.ts | 104 --- packages/flow/lib/dispatch-tool.test.ts | 111 --- packages/flow/lib/dispatch-tool.ts | 87 -- packages/flow/lib/launch.test.ts | 94 +- packages/flow/lib/launch.ts | 37 +- packages/flow/lib/opencode-process.test.ts | 171 ---- packages/flow/lib/opencode-process.ts | 301 ------- packages/flow/lib/repackage.test.ts | 83 -- packages/flow/lib/repackage.ts | 137 --- packages/flow/lib/store.test.ts | 952 +++++++++------------ packages/flow/lib/store.ts | 862 +++++-------------- packages/flow/lib/zen.test.ts | 94 -- packages/flow/lib/zen.ts | 72 -- packages/flow/mcp/dispatch.ts | 156 ---- packages/flow/package.json | 7 +- packages/flow/server.ts | 172 +--- packages/flow/vite.config.ts | 59 +- packages/flow/vite/flow-store.ts | 62 +- 24 files changed, 769 insertions(+), 3038 deletions(-) delete mode 100644 packages/flow/lib/cli-auth.test.ts delete mode 100644 packages/flow/lib/cli-auth.ts delete mode 100644 packages/flow/lib/dispatch-tool.test.ts delete mode 100644 packages/flow/lib/dispatch-tool.ts delete mode 100644 packages/flow/lib/opencode-process.test.ts delete mode 100644 packages/flow/lib/opencode-process.ts delete mode 100644 packages/flow/lib/repackage.test.ts delete mode 100644 packages/flow/lib/repackage.ts delete mode 100644 packages/flow/lib/zen.test.ts delete mode 100644 packages/flow/lib/zen.ts delete mode 100644 packages/flow/mcp/dispatch.ts diff --git a/.claude/launch.json b/.claude/launch.json index eeb1273335..49412dcf08 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -1,12 +1,6 @@ { "version": "0.0.1", "configurations": [ - { - "name": "opencode-server", - "runtimeExecutable": "bun", - "runtimeArgs": ["run", "--cwd", "packages/opencode", "--conditions=browser", "src/index.ts", "serve", "--port", "4096"], - "port": 4096 - }, { "name": "openflow", "runtimeExecutable": "bun", diff --git a/bun.lock b/bun.lock index fcb4ed8a0d..8cfd9ea91a 100644 --- a/bun.lock +++ b/bun.lock @@ -493,14 +493,17 @@ }, "packages/flow": { "name": "@openflow/flow", - "version": "1.2.1", + "version": "1.3.0", "dependencies": { - "@opencode-ai/sdk": "workspace:*", + "@lydell/node-pty": "catalog:", + "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", "solid-js": "catalog:", + "ws": "8.21.0", }, "devDependencies": { "@types/bun": "catalog:", "@types/node": "catalog:", + "@types/ws": "8.18.1", "@typescript/native-preview": "catalog:", "typescript": "catalog:", "vite": "catalog:", diff --git a/openflow.ps1 b/openflow.ps1 index 780e29d9b0..c038112477 100644 --- a/openflow.ps1 +++ b/openflow.ps1 @@ -8,9 +8,7 @@ # Usage: # ./openflow.ps1 # agents work in this repo # ./openflow.ps1 -Project C:\code\my-app # point the agents at another repo -# ./openflow.ps1 -ServerPort 4097 # use a non-default engine port # ./openflow.ps1 -Built # serve the built bundle (no vite) -# ./openflow.ps1 -Manage # let the canvas own the engine [CmdletBinding()] param( @@ -18,17 +16,9 @@ param( # at the project you actually want changed. [string]$Project, - # Port for `opencode serve`. The canvas proxy is pointed here automatically. - [int]$ServerPort = 4096, - # Build the canvas and serve it with `server.ts` instead of running vite. [switch]$Built, - # Hand the engine to the canvas: it spawns it, waits for it, and can then - # restart it from the UI. The server has no shutdown route, so only the - # process that started it can do that. - [switch]$Manage, - [switch]$Help ) @@ -37,17 +27,14 @@ $repo = $PSScriptRoot if ($Help) { Write-Host @" -OpenFlow — starts the engine and the canvas, then opens the browser. +OpenFlow — starts the canvas, then opens the browser. -Usage: ./openflow.ps1 [-Project ] [-ServerPort ] [-Built] [-Manage] [-Help] +Usage: ./openflow.ps1 [-Project ] [-Built] [-Help] -Project Repo the agents read and write (default: this repo). These agents edit real files — point this at the project you actually want changed. - -ServerPort Port for ``opencode serve`` (default: 4096). -Built Serve the built bundle instead of the vite dev server. - -Manage Let the canvas own the engine, which enables the UI's - "restart engine" button. -Help Show this help. (PowerShell answers -? itself, with the one-line parameter syntax.) @@ -55,11 +42,10 @@ Environment: OPENFLOW_DRY_RUN=1 Print the resolved plan and exit without starting anything — useful for checking how flags resolved. -Connect a provider before the first run: click "api keys" in the canvas -titlebar, pick a provider, paste its key — models appear immediately. Keys the -opencode CLI already holds are offered for import there; the server does not -read its ``auth.json`` for the model catalog, so importing is what makes them -count. An empty model menu means no provider is connected. +Cards run on omp, which must be on PATH: the launcher prints the version it +finds, or what to fix, and starts either way. Connect a provider before the +first run: open Omp settings in the canvas and log in. An empty model menu +means no provider is connected. "@ exit 0 } @@ -69,7 +55,6 @@ if (-not (Get-Command bun -ErrorAction SilentlyContinue)) { exit 1 } -$env:OPENCODE_SERVER_URL = "http://127.0.0.1:$ServerPort" if ($Project) { # Resolve-Path throws a raw provider exception on a bad path; say what is # actually wrong instead. @@ -81,7 +66,6 @@ if ($Project) { $env:OPENFLOW_PROJECT = $resolved.Path } if ($Built) { $env:OPENFLOW_BUILT = "1" } -if ($Manage) { $env:FLOW_MANAGE_SERVER = "1" } bun (Join-Path $repo "openflow.ts") exit $LASTEXITCODE diff --git a/openflow.sh b/openflow.sh index fb41bdc331..4077b6c9a5 100755 --- a/openflow.sh +++ b/openflow.sh @@ -12,39 +12,32 @@ # Usage: # ./openflow.sh # dev canvas, agents work in this repo # ./openflow.sh -p ~/code/my-app # point the agents at another repo -# ./openflow.sh -s 4097 # use a non-default engine port # ./openflow.sh -b # serve the built bundle (no vite) -# ./openflow.sh -m # let the canvas own the engine set -euo pipefail repo="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" project="" -server_port=4096 usage() { cat <<'EOF' -OpenFlow — starts the engine and the canvas, then opens the browser. +OpenFlow — starts the canvas, then opens the browser. -Usage: ./openflow.sh [-p ] [-s ] [-b] [-m] [-h] +Usage: ./openflow.sh [-p ] [-b] [-h] -p, --project Repo the agents read and write (default: this repo). These agents edit real files — point this at the project you actually want changed. - -s, --server-port Port for `opencode serve` (default: 4096). -b, --built Serve the built bundle instead of the vite dev server. - -m, --manage Let the canvas own the engine, which enables the UI's - "restart engine" button. -h, --help Show this help. Environment: OPENFLOW_DRY_RUN=1 Print the resolved plan and exit without starting anything — useful for checking how flags resolved. -Connect a provider before the first run: click "api keys" in the canvas -titlebar, pick a provider, paste its key — models appear immediately. Keys the -opencode CLI already holds are offered for import there; the server does not -read its `auth.json` for the model catalog, so importing is what makes them -count. An empty model menu means no provider is connected. +Cards run on omp, which must be on PATH: the launcher prints the version it +finds, or what to fix, and starts either way. Connect a provider before the +first run: open Omp settings in the canvas and log in. An empty model menu +means no provider is connected. EOF } @@ -53,14 +46,7 @@ while [ $# -gt 0 ]; do -p|--project) [ $# -ge 2 ] || { echo "$1 needs a directory. Use -h for help." >&2; exit 1; } project="$2"; shift 2 ;; - -s|--server-port) - [ $# -ge 2 ] || { echo "$1 needs a port. Use -h for help." >&2; exit 1; } - server_port="$2"; shift 2 ;; -b|--built) export OPENFLOW_BUILT=1; shift ;; - # Hand the engine to the canvas: it spawns it, waits for it, and can then - # restart it from the UI. The server has no shutdown route, so only the - # process that started it can do that. - -m|--manage) export FLOW_MANAGE_SERVER=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "Unknown flag: $1. Use -h for help." >&2; exit 1 ;; esac @@ -71,8 +57,6 @@ command -v bun >/dev/null 2>&1 || { exit 1 } -# Keep the canvas proxy pointed at whatever engine port we use. -export OPENCODE_SERVER_URL="http://127.0.0.1:${server_port}" if [ -n "$project" ]; then [ -d "$project" ] || { echo "Project directory not found: $project" >&2; exit 1; } OPENFLOW_PROJECT="$(cd "$project" && pwd)" diff --git a/openflow.ts b/openflow.ts index 5c86880af0..251dec5f84 100644 --- a/openflow.ts +++ b/openflow.ts @@ -1,16 +1,15 @@ #!/usr/bin/env bun /** - * OpenFlow launcher — one command, both processes. + * OpenFlow launcher — one command. * - * Starts `opencode serve` (the engine, :4096) and the canvas (:5174), waits - * until the engine actually answers, then prints the URL — so a non-dev never - * juggles two terminals. Ports already serving are reused, not double-started. + * Starts the canvas (:5174), whose host owns the Omp engine (one `omp --mode + * rpc-ui` child per card), then prints the URL. A canvas already serving its + * port is reused, not double-started. A missing `omp` is reported, not fatal: + * the app explains it and links to the fix in its Omp settings. * * bun openflow.ts * OPENFLOW_PROJECT=/path/to/app bun openflow.ts # agents edit that repo - * OPENCODE_SERVER_URL=http://127.0.0.1:4097 bun openflow.ts * OPENFLOW_BUILT=1 bun openflow.ts # build, then serve dist/ - * FLOW_MANAGE_SERVER=1 bun openflow.ts # canvas owns the engine * OPENFLOW_DRY_RUN=1 bun openflow.ts # print the plan, start nothing * * `openflow.ps1` and `openflow.sh` are shims that set exactly these variables, @@ -19,11 +18,10 @@ * waits, and tears down. */ import { launchPlan } from "./packages/flow/lib/launch" -import { healthy } from "./packages/flow/lib/opencode-process" +import { checkOmp } from "./packages/flow/lib/omp/binary" +import { DEFAULT_OMP_SETTINGS } from "./packages/flow/shared/omp" const repo = import.meta.dir -const ENGINE_TIMEOUT = 90_000 -const POLL = 500 const children: Bun.Subprocess[] = [] let quitting = false @@ -51,7 +49,7 @@ async function pipe(stream: ReadableStream | number | undefined, pre * answers HTTP yet (or ever again), so `skip` comes back false and the child * we spawn next fails to bind. Only called right before spawning a child * whose probe already said "not up", so a genuinely live sibling session - * (probe says healthy) is never touched. + * (probe says reachable) is never touched. */ function freePort(port: string) { if (process.platform === "win32") { @@ -105,7 +103,7 @@ function teardown(code: number) { process.exit(code) } -/** If a child dies on its own, take the other down with the same code. */ +/** If a child dies on its own, stop the launcher with the same code. */ function watch(child: Bun.Subprocess, name: string) { void child.exited.then((code) => { if (quitting) return @@ -137,53 +135,39 @@ function openBrowser(url: string) { } } +/** + * One line saying which omp the app will find, or what to do about it. Never + * fails the launch: without omp the app still opens and its Omp settings say + * what is wrong. Only the default settings are known here — a binary path or + * profile saved in the app is the host's to resolve. + */ +async function ompPreflight() { + const status = await checkOmp(DEFAULT_OMP_SETTINGS).catch(() => undefined) + if (status?.available) return `omp ${status.version}` + const problem = status?.problems[0] + const fix = problem?.fix ? ` — ${problem.fix}` : "" + return `warning: ${(problem?.message ?? "omp could not be checked").replace(/\.$/, "")}${fix}` +} + async function main() { process.on("SIGINT", () => teardown(0)) process.on("SIGTERM", () => teardown(0)) - const plan = await launchPlan({ - env: process.env, - repo, - // engine readiness is a real health check; the canvas has no /api/health. - probe: (url) => (url.includes("5174") ? reachable(url) : healthy(url)), - }) + const plan = await launchPlan({ env: process.env, repo, probe: reachable }) + const omp = await ompPreflight() if (process.env.OPENFLOW_DRY_RUN) { console.log("OpenFlow — dry run, nothing started") console.log(` project : ${process.env.OPENFLOW_PROJECT ?? `${repo} (this repo)`}`) - console.log(` engine : ${plan.engineUrl}`) console.log(` canvas : ${plan.canvasUrl}`) console.log(` mode : ${plan.built ? "built bundle (no vite)" : "vite dev server"}`) - console.log( - ` engine cmd : ${plan.managed ? "started by the canvas (FLOW_MANAGE_SERVER)" : plan.engine.argv.join(" ")}`, - ) + console.log(` omp : ${omp}`) if (plan.canvas.prebuild) console.log(` build cmd : ${plan.canvas.prebuild.join(" ")}`) console.log(` canvas cmd : ${plan.canvas.argv.join(" ")}`) return } - if (plan.managed) console.log(`engine ${plan.engineUrl} will be started and owned by the canvas`) - if (!plan.managed && plan.engine.skip) console.log(`engine already running on ${plan.engineUrl} — reusing it`) - if (!plan.engine.skip) { - freePort(new URL(plan.engineUrl).port || "4096") - console.log(`starting engine: ${plan.engine.argv.join(" ")}`) - spawnChild(plan.engine.argv, "engine") - } - - const deadline = Date.now() + ENGINE_TIMEOUT - let ready = plan.engine.skip - while (!ready && Date.now() < deadline) { - if (await healthy(plan.engineUrl)) { - ready = true - break - } - await Bun.sleep(POLL) - } - if (!ready) { - console.error(`engine did not answer ${plan.engineUrl} within ${ENGINE_TIMEOUT / 1000}s`) - return teardown(1) - } - if (!plan.managed) console.log(`engine ready on ${plan.engineUrl}`) + console.log(omp) if (plan.canvas.skip) console.log(`canvas already serving ${plan.canvasUrl} — reusing it`) if (!plan.canvas.skip) { diff --git a/packages/flow/lib/cli-auth.test.ts b/packages/flow/lib/cli-auth.test.ts deleted file mode 100644 index 2d4499c0c0..0000000000 --- a/packages/flow/lib/cli-auth.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, test } from "bun:test" -import path from "node:path" -import { cliAuthPath, importCliKeys, parseCliKeys } from "./cli-auth" - -describe("cliAuthPath", () => { - test("follows XDG_DATA_HOME when it is set", () => { - expect(cliAuthPath({ XDG_DATA_HOME: "/data" }, "/home/x")).toBe(path.join("/data", "opencode", "auth.json")) - }) - - test("falls back to ~/.local/share, which is where the CLI writes on every platform", () => { - expect(cliAuthPath({}, "/home/x")).toBe(path.join("/home/x", ".local", "share", "opencode", "auth.json")) - }) -}) - -describe("parseCliKeys", () => { - test("takes api keys and skips oauth entries, which connect/key cannot accept", () => { - const raw = JSON.stringify({ - groq: { type: "api", key: "gsk_1" }, - anthropic: { type: "oauth", refresh: "r", access: "a", expires: 1 }, - openrouter: { type: "api", key: "sk-or-1" }, - }) - expect(parseCliKeys(raw)).toEqual([ - { providerID: "groq", key: "gsk_1" }, - { providerID: "openrouter", key: "sk-or-1" }, - ]) - }) - - test("drops empty keys and malformed entries instead of storing junk", () => { - const raw = JSON.stringify({ a: { type: "api", key: "" }, b: { type: "api" }, c: "nope", d: null }) - expect(parseCliKeys(raw)).toEqual([]) - }) - - test("normalises the trailing slash the CLI tolerates in provider names", () => { - expect(parseCliKeys(JSON.stringify({ "groq/": { type: "api", key: "k" } }))).toEqual([ - { providerID: "groq", key: "k" }, - ]) - }) - - test("unparseable auth.json yields nothing rather than throwing at the host", () => { - expect(parseCliKeys("{not json")).toEqual([]) - }) -}) - -describe("importCliKeys", () => { - const keys = [ - { providerID: "groq", key: "gsk_1" }, - { providerID: "openrouter", key: "sk-or-1" }, - ] - - test("posts each key to the integration connect route", async () => { - const calls: Array<{ url: string; body: any }> = [] - const results = await importCliKeys({ - upstream: "http://127.0.0.1:4096", - keys, - fetchImpl: (async (url: any, init: any) => { - calls.push({ url: String(url), body: JSON.parse(init.body) }) - return new Response(null, { status: 204 }) - }) as any, - }) - expect(calls.map((call) => call.url)).toEqual([ - "http://127.0.0.1:4096/api/integration/groq/connect/key", - "http://127.0.0.1:4096/api/integration/openrouter/connect/key", - ]) - expect(calls[0].body).toEqual({ key: "gsk_1", label: "opencode cli" }) - expect(results.every((result) => result.ok)).toBe(true) - }) - - test("only imports the providers asked for", async () => { - const seen: string[] = [] - await importCliKeys({ - upstream: "http://127.0.0.1:4096", - keys, - only: ["openrouter"], - fetchImpl: (async (url: any) => { - seen.push(String(url)) - return new Response(null, { status: 204 }) - }) as any, - }) - expect(seen).toHaveLength(1) - expect(seen[0]).toContain("/openrouter/") - }) - - test("reports a failing provider without abandoning the rest", async () => { - const results = await importCliKeys({ - upstream: "http://127.0.0.1:4096", - keys, - fetchImpl: (async (url: any) => - String(url).includes("groq") - ? new Response("no such integration", { status: 404 }) - : new Response(null, { status: 204 })) as any, - }) - expect(results[0]).toMatchObject({ providerID: "groq", ok: false }) - expect(results[0].error).toContain("404") - expect(results[1]).toEqual({ providerID: "openrouter", ok: true }) - }) - - test("a transport failure is a per-provider error, not a thrown import", async () => { - const results = await importCliKeys({ - upstream: "http://127.0.0.1:4096", - keys: [keys[0]], - fetchImpl: (async () => { - throw new Error("connection refused") - }) as any, - }) - expect(results).toEqual([{ providerID: "groq", ok: false, error: "connection refused" }]) - }) -}) diff --git a/packages/flow/lib/cli-auth.ts b/packages/flow/lib/cli-auth.ts deleted file mode 100644 index d991b7f139..0000000000 --- a/packages/flow/lib/cli-auth.ts +++ /dev/null @@ -1,104 +0,0 @@ -import fs from "node:fs/promises" -import os from "node:os" -import path from "node:path" - -/** - * The keys `opencode providers login` already stored, so OpenFlow can adopt - * them instead of asking for them again. - * - * The CLI writes provider credentials to `auth.json` in the opencode data - * directory. The server OpenFlow drives does not read that file for its model - * catalog — it keys providers through the integration store instead — so an - * account with four `auth.json` keys still sees nothing but the free zen - * models. Importing means reading each one and connecting it as an - * integration. - * - * Keys stay on this side: the browser is told which providers are importable, - * never their secrets, and the connect call is made from here. - */ -export type CliKey = { providerID: string; key: string } - -/** Mirrors `Global.Path.data` (xdg-basedir), which is where the CLI writes. */ -export function cliAuthPath(env: Record = process.env, home = os.homedir()) { - const data = env.XDG_DATA_HOME || path.join(home, ".local", "share") - return path.join(data, "opencode", "auth.json") -} - -/** - * API keys from `auth.json`, plus whatever `OPENCODE_AUTH_CONTENT` carries — - * the same two sources the CLI itself reads, in the same order. - * - * OAuth entries are skipped: a refresh token is not something - * `connect/key` can accept, and pretending otherwise would store a string - * that fails at the first request. - */ -export async function readCliKeys( - file = cliAuthPath(), - env: Record = process.env, -): Promise { - const raw = env.OPENCODE_AUTH_CONTENT ?? (await fs.readFile(file, "utf8").catch(() => undefined)) - if (!raw) return [] - return parseCliKeys(raw) -} - -export function parseCliKeys(raw: string): CliKey[] { - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch { - return [] - } - if (!parsed || typeof parsed !== "object") return [] - const out: CliKey[] = [] - for (const [providerID, value] of Object.entries(parsed as Record)) { - if (!value || typeof value !== "object") continue - if (value.type !== "api") continue - if (typeof value.key !== "string" || !value.key) continue - out.push({ providerID: providerID.replace(/\/+$/, ""), key: value.key }) - } - return out -} - -export type ImportResult = { providerID: string; ok: boolean; error?: string } - -/** - * Connects each key to the running `opencode serve` as an integration. - * - * The server stores what it is given without checking it, so a stale CLI key - * imports "successfully" and only fails when a node runs. Success here means - * stored, not valid. - */ -export async function importCliKeys(input: { - upstream: string - keys: CliKey[] - only?: string[] - fetchImpl?: typeof fetch -}): Promise { - const call = input.fetchImpl ?? fetch - const wanted = input.only?.length ? new Set(input.only) : undefined - const out: ImportResult[] = [] - for (const entry of input.keys) { - if (wanted && !wanted.has(entry.providerID)) continue - const url = new URL(`/api/integration/${encodeURIComponent(entry.providerID)}/connect/key`, input.upstream) - try { - const response = await call(url, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ key: entry.key, label: "opencode cli" }), - }) - if (response.ok) { - out.push({ providerID: entry.providerID, ok: true }) - continue - } - const text = await response.text().catch(() => "") - out.push({ providerID: entry.providerID, ok: false, error: `${response.status} ${text.slice(0, 200)}`.trim() }) - } catch (error) { - out.push({ - providerID: entry.providerID, - ok: false, - error: error instanceof Error ? error.message : String(error), - }) - } - } - return out -} diff --git a/packages/flow/lib/dispatch-tool.test.ts b/packages/flow/lib/dispatch-tool.test.ts deleted file mode 100644 index 2c9603beb4..0000000000 --- a/packages/flow/lib/dispatch-tool.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, test } from "bun:test" -import path from "node:path" -import { install, installed, SERVER_NAME, serverEntry, uninstall } from "./dispatch-tool" - -const ROOT = path.join("C:", "checkout", "packages", "flow") -const OTHER = path.join("D:", "moved", "packages", "flow") -const BUN = path.join("C:", "bun", "bun.exe") -const OTHER_BUN = path.join("D:", "bun", "bun.exe") - -describe("serverEntry", () => { - test("starts the server from this package with bun", () => { - expect(serverEntry(ROOT, BUN)).toEqual({ - type: "local", - command: [BUN, path.join(ROOT, "mcp", "dispatch.ts")], - enabled: true, - }) - }) -}) - -describe("installed", () => { - test("a config with nothing in it has no server", () => { - expect(installed({}, ROOT, BUN)).toEqual({ present: false, current: false }) - expect(installed(undefined, ROOT, BUN)).toEqual({ present: false, current: false }) - }) - - test("recognises its own entry", () => { - expect(installed(install({}, ROOT, BUN).value, ROOT, BUN)).toEqual({ present: true, current: true }) - }) - - test("an entry pointing at a checkout that moved is present but not current", () => { - // Worse than nothing: the card is told the tool exists, the spawn fails, - // and the failure reads as a broken model. - const stale = install({}, OTHER, BUN).value - expect(installed(stale, ROOT, BUN)).toEqual({ present: true, current: false }) - }) -}) - -describe("install", () => { - test("adds the server without touching anything else", () => { - const before = { $schema: "x", mcp: { other: { type: "local", command: ["a"] } }, provider: { p: {} } } - const after = install(before, ROOT, BUN) - - expect(after.changed).toBe(true) - expect(after.value.mcp[SERVER_NAME]).toEqual(serverEntry(ROOT, BUN)) - expect(after.value.mcp.other).toEqual(before.mcp.other) - expect(after.value.provider).toEqual(before.provider) - }) - - test("installing twice changes nothing the second time", () => { - // The caller skips the write, and the engine restart, on `changed: false`. - const once = install({}, ROOT, BUN) - expect(install(once.value, ROOT, BUN).changed).toBe(false) - }) - - test("repoints a stale entry rather than leaving it", () => { - const stale = install({}, OTHER, BUN) - const fixed = install(stale.value, ROOT, BUN) - expect(fixed.changed).toBe(true) - expect(fixed.value.mcp[SERVER_NAME].command[1]).toBe(path.join(ROOT, "mcp", "dispatch.ts")) - }) - - test("does not mutate the config it was handed", () => { - const before = { mcp: {} } - install(before, ROOT, BUN) - expect(before.mcp).toEqual({}) - }) -}) - -describe("uninstall", () => { - test("removes only our entry", () => { - const config = install({ mcp: { other: { type: "local" } } }, ROOT, BUN).value - const after = uninstall(config) - - expect(after.changed).toBe(true) - expect(after.value.mcp[SERVER_NAME]).toBeUndefined() - expect(after.value.mcp.other).toBeTruthy() - }) - - test("removing what is not there is not a change", () => { - expect(uninstall({ mcp: {} }).changed).toBe(false) - expect(uninstall({}).changed).toBe(false) - }) - - test("takes the mcp key with it when ours was the only server", () => { - // The config should come back exactly as it was. An empty `mcp: {}` is - // inert, but it is a key this tool put there. - const before = { $schema: "x", provider: { p: {} } } - const after = uninstall(install(before, ROOT, BUN).value) - - expect(after.changed).toBe(true) - expect(after.value).toEqual(before) - expect("mcp" in after.value).toBe(false) - }) -}) - -describe("the runtime path", () => { - // Measured: a bare `bun` command never started, because opencode spawns a - // local MCP server without a shell and Windows does not resolve it through - // PATHEXT. The card was offered the tool, called it correctly, and was told - // "Unknown tool". - test("is written absolute, never as a bare command name", () => { - expect(serverEntry(ROOT, BUN).command[0]).toBe(BUN) - expect(serverEntry(ROOT, BUN).command[0]).not.toBe("bun") - }) - - test("a config written by a different runtime is repointed", () => { - const other = install({}, ROOT, OTHER_BUN).value - expect(installed(other, ROOT, BUN)).toEqual({ present: true, current: false }) - expect(install(other, ROOT, BUN).changed).toBe(true) - }) -}) diff --git a/packages/flow/lib/dispatch-tool.ts b/packages/flow/lib/dispatch-tool.ts deleted file mode 100644 index 07ceb9353f..0000000000 --- a/packages/flow/lib/dispatch-tool.ts +++ /dev/null @@ -1,87 +0,0 @@ -import path from "node:path" - -/** - * Registers OpenFlow's dispatch MCP server so an orchestrator card can call it. - * - * The server itself is `mcp/dispatch.ts` in this package, and the reason it - * exists is in its header: measured against real providers, a text-only control - * protocol loses three ways, and the third — a good model trying to *call* - * `dispatch` as a tool — says plainly what models want the channel to be. - * - * It has to be the **global** config, for the same reason the provider - * repackage does: a session's location is the engine's cwd, never - * `OPENFLOW_PROJECT` (see FLOW.md), so a project `opencode.json` reaches the - * catalog reads the browser makes and never the drain that runs the card. The - * global config is loaded for both. - * - * Nothing here is written without an explicit request from the panel, and the - * file is backed up first, because this is the user's machine-wide config. - */ - -export const SERVER_NAME = "openflow" - -/** - * How opencode is told to start it. - * - * `runtime` is an absolute path to the bun binary, not the bare word `bun`. - * opencode spawns a local MCP server without a shell, and a bare command on - * Windows is not resolved through PATHEXT — measured: the card was offered - * `openflow_dispatch`, called it correctly, and got "Unknown tool" back because - * the server had never started. The host passes `process.execPath`, which is - * the bun already running it. - */ -export function serverEntry(packageRoot: string, runtime: string) { - return { - type: "local" as const, - command: [runtime, path.join(packageRoot, "mcp", "dispatch.ts")], - enabled: true, - } -} - -/** - * Whether this config already starts our server, and from the right file. - * - * A stale entry — one pointing at a checkout that has moved — is worse than no - * entry: the card is told the tool exists, the spawn fails, and the failure - * reads as a broken model. So the command is compared, not just the name. - */ -export function installed(config: any, packageRoot: string, runtime: string) { - const entry = config?.mcp?.[SERVER_NAME] - if (!entry) return { present: false, current: false } - const want = serverEntry(packageRoot, runtime).command - const have: string[] = Array.isArray(entry.command) ? entry.command : [] - return { present: true, current: have.length === want.length && have.every((part, index) => part === want[index]) } -} - -/** - * Adds or repoints the server. Returns whether anything changed, so a caller - * can skip the write — and the engine restart — when it would be a no-op. - * - * `mcp` is the same key in both config dialects, so unlike the provider - * repackage this needs no v1/v2 branch. - */ -export function install(config: any, packageRoot: string, runtime: string) { - const state = installed(config, packageRoot, runtime) - if (state.current) return { changed: false, value: config } - return { - changed: true, - value: { ...config, mcp: { ...(config?.mcp ?? {}), [SERVER_NAME]: serverEntry(packageRoot, runtime) } }, - } -} - -/** - * Removes the server, for a user who wants their config back. - * - * "Back" means exactly the shape it had: an `mcp` key left behind holding `{}` - * is inert, but it is a key this tool put there, and someone reading the file - * later would reasonably wonder what used to be in it. - */ -export function uninstall(config: any) { - if (!config?.mcp?.[SERVER_NAME]) return { changed: false, value: config } - const mcp = { ...config.mcp } - delete mcp[SERVER_NAME] - if (Object.keys(mcp).length) return { changed: true, value: { ...config, mcp } } - const next = { ...config } - delete next.mcp - return { changed: true, value: next } -} diff --git a/packages/flow/lib/launch.test.ts b/packages/flow/lib/launch.test.ts index d8faa6378d..b0f6f84b1b 100644 --- a/packages/flow/lib/launch.test.ts +++ b/packages/flow/lib/launch.test.ts @@ -4,76 +4,42 @@ import { launchPlan } from "./launch" const repo = "/repo" describe("launchPlan", () => { - test("defaults to port 4096 and the localhost canvas", async () => { + test("defaults to the localhost canvas under vite", async () => { const plan = await launchPlan({ env: {}, repo }) - expect(plan.engineUrl).toBe("http://127.0.0.1:4096") expect(plan.canvasUrl).toBe("http://localhost:5174") - expect(plan.engine.argv).toEqual([ - "bun", - "run", - "--cwd", - "packages/opencode", - "--conditions=browser", - "src/index.ts", - "serve", - "--port", - "4096", - ]) expect(plan.canvas.argv).toEqual(["bun", "--cwd", "packages/flow", "dev"]) expect(plan.canvas.prebuild).toBeUndefined() expect(plan.built).toBe(false) - expect(plan.managed).toBe(false) }) - test("OPENCODE_SERVER_URL wins and its port reaches the engine argv", async () => { - const plan = await launchPlan({ env: { OPENCODE_SERVER_URL: "http://127.0.0.1:4097" }, repo }) - expect(plan.engineUrl).toBe("http://127.0.0.1:4097") - expect(plan.engine.argv.at(-1)).toBe("4097") + test("plans a canvas and nothing else", async () => { + const plan = await launchPlan({ env: {}, repo }) + expect(Object.keys(plan).sort()).toEqual(["built", "canvas", "canvasUrl"]) }) - test("nothing is skipped when no port answers", async () => { - const plan = await launchPlan({ env: {}, repo, probe: async () => false }) - expect(plan.engine.skip).toBe(false) - expect(plan.canvas.skip).toBe(false) + test("no engine variable changes the plan", async () => { + const plain = await launchPlan({ env: {}, repo }) + const stale = await launchPlan({ + env: { OPENCODE_SERVER_URL: "http://127.0.0.1:4097", FLOW_MANAGE_SERVER: "1" }, + repo, + }) + expect(stale).toEqual(plain) }) - test("a probe that reports the engine up skips only the engine", async () => { - const plan = await launchPlan({ env: {}, repo, probe: async (url) => url.includes("4096") }) - expect(plan.engine.skip).toBe(true) + test("nothing is skipped when the port does not answer", async () => { + const plan = await launchPlan({ env: {}, repo, probe: async () => false }) expect(plan.canvas.skip).toBe(false) }) - test("a probe that reports the canvas up skips only the canvas", async () => { + test("a probe that reports the canvas up skips it", async () => { const plan = await launchPlan({ env: {}, repo, probe: async (url) => url.includes("5174") }) - expect(plan.engine.skip).toBe(false) expect(plan.canvas.skip).toBe(true) }) - test("OPENFLOW_BUILT builds then serves the bundle instead of running vite", async () => { - const plan = await launchPlan({ env: { OPENFLOW_BUILT: "1" }, repo }) - expect(plan.built).toBe(true) - expect(plan.canvas.prebuild).toEqual(["bun", "run", "--cwd", "packages/flow", "build"]) - expect(plan.canvas.argv).toEqual(["bun", "run", "--cwd", "packages/flow", "serve"]) - expect(plan.canvas.argv).not.toContain("dev") - }) - - test("built mode leaves the engine plan alone", async () => { - const plan = await launchPlan({ env: { OPENFLOW_BUILT: "1", OPENCODE_SERVER_URL: "http://127.0.0.1:4097" }, repo }) - expect(plan.engine.skip).toBe(false) - expect(plan.engine.argv.at(-1)).toBe("4097") - }) - - test("FLOW_MANAGE_SERVER hands the engine to the canvas, so the launcher skips it", async () => { - const plan = await launchPlan({ env: { FLOW_MANAGE_SERVER: "1" }, repo, probe: async () => false }) - expect(plan.managed).toBe(true) - expect(plan.engine.skip).toBe(true) - expect(plan.canvas.skip).toBe(false) - }) - - test("a managed engine is never probed — nothing is serving it yet", async () => { + test("only the canvas is probed", async () => { const probed: string[] = [] const plan = await launchPlan({ - env: { FLOW_MANAGE_SERVER: "1" }, + env: {}, repo, probe: async (url) => { probed.push(url) @@ -83,23 +49,25 @@ describe("launchPlan", () => { expect(probed).toEqual([plan.canvasUrl]) }) - test("built and manage combine, and the port override still reaches the engine argv", async () => { - const plan = await launchPlan({ - env: { OPENFLOW_BUILT: "1", FLOW_MANAGE_SERVER: "1", OPENCODE_SERVER_URL: "http://127.0.0.1:4097" }, - repo, - }) + test("OPENFLOW_BUILT builds then serves the bundle instead of running vite", async () => { + const plan = await launchPlan({ env: { OPENFLOW_BUILT: "1" }, repo }) expect(plan.built).toBe(true) - expect(plan.managed).toBe(true) - expect(plan.engineUrl).toBe("http://127.0.0.1:4097") - expect(plan.engine.argv.at(-1)).toBe("4097") + expect(plan.canvas.prebuild).toEqual(["bun", "run", "--cwd", "packages/flow", "build"]) expect(plan.canvas.argv).toEqual(["bun", "run", "--cwd", "packages/flow", "serve"]) + expect(plan.canvas.argv).not.toContain("dev") + }) + + test("a reachable canvas is reused in built mode too, build included", async () => { + const plan = await launchPlan({ env: { OPENFLOW_BUILT: "1" }, repo, probe: async () => true }) + expect(plan.canvas.skip).toBe(true) + expect(plan.canvas.prebuild).toBeDefined() }) test("an explicitly off flag reads as off", async () => { - const plan = await launchPlan({ env: { OPENFLOW_BUILT: "0", FLOW_MANAGE_SERVER: "false" }, repo }) - expect(plan.built).toBe(false) - expect(plan.managed).toBe(false) - expect(plan.canvas.argv).toEqual(["bun", "--cwd", "packages/flow", "dev"]) - expect(plan.engine.skip).toBe(false) + for (const value of ["0", "false", " FALSE ", ""]) { + const plan = await launchPlan({ env: { OPENFLOW_BUILT: value }, repo }) + expect(plan.built).toBe(false) + expect(plan.canvas.argv).toEqual(["bun", "--cwd", "packages/flow", "dev"]) + } }) }) diff --git a/packages/flow/lib/launch.ts b/packages/flow/lib/launch.ts index 56839995a7..49e0e43b37 100644 --- a/packages/flow/lib/launch.ts +++ b/packages/flow/lib/launch.ts @@ -1,7 +1,7 @@ /** * Pure argv/URL resolution for the cross-platform launcher (`openflow.ts` at * the repo root). Kept here, testable, with no real I/O: the launcher itself - * spawns the processes, but every decision about *what* to spawn and *whether* + * spawns the process, but every decision about *what* to spawn and *whether* * to skip a process already serving its port lives in `launchPlan`, driven by * an injected `probe` so a unit test needs no live server. * @@ -22,17 +22,12 @@ export type LaunchEnv = Record export type ChildPlan = { argv: string[]; skip: boolean; prebuild?: string[] } export type LaunchPlan = { - engine: ChildPlan canvas: ChildPlan - engineUrl: string canvasUrl: string - /** The canvas host owns `opencode serve`, so the launcher must not start one. */ - managed: boolean /** Serving `dist/` through `server.ts` instead of running vite. */ built: boolean } -const DEFAULT_ENGINE_URL = "http://127.0.0.1:4096" const CANVAS_URL = "http://localhost:5174" /** Shims set `1`; treat an explicit off value as off rather than as "present". */ @@ -44,41 +39,19 @@ const enabled = (value: string | undefined) => { /** * `probe(url)` answers "is something already serving this URL?" — injected so * the test controls it and production passes a real reachability check. A - * process whose port already answers is marked `skip: true`, matching the - * "check before starting your own" rule: never start a second engine or canvas. + * canvas whose port already answers is marked `skip: true`, matching the + * "check before starting your own" rule: never start a second canvas. */ export async function launchPlan(input: { env: LaunchEnv repo: string probe?: (url: string) => Promise }): Promise { - const engineUrl = input.env.OPENCODE_SERVER_URL?.trim() || DEFAULT_ENGINE_URL - const port = new URL(engineUrl).port || "4096" const probe = input.probe ?? (async () => false) - const managed = enabled(input.env.FLOW_MANAGE_SERVER) const built = enabled(input.env.OPENFLOW_BUILT) - - // Under FLOW_MANAGE_SERVER the canvas host spawns the engine and can restart - // it from the UI — only the process that started it can, since the server has - // no shutdown route. So the launcher neither starts nor waits for one: `skip` - // here means "not ours to run", not "already up", hence no probe. - const [engineUp, canvasUp] = await Promise.all([managed || probe(engineUrl), probe(CANVAS_URL)]) + const canvasUp = await probe(CANVAS_URL) return { - engine: { - argv: [ - "bun", - "run", - "--cwd", - "packages/opencode", - "--conditions=browser", - "src/index.ts", - "serve", - "--port", - port, - ], - skip: engineUp, - }, canvas: built ? { argv: ["bun", "run", "--cwd", "packages/flow", "serve"], @@ -86,9 +59,7 @@ export async function launchPlan(input: { skip: canvasUp, } : { argv: ["bun", "--cwd", "packages/flow", "dev"], skip: canvasUp }, - engineUrl, canvasUrl: CANVAS_URL, - managed, built, } } diff --git a/packages/flow/lib/opencode-process.test.ts b/packages/flow/lib/opencode-process.test.ts deleted file mode 100644 index fceeafb376..0000000000 --- a/packages/flow/lib/opencode-process.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import fs from "node:fs/promises" -import os from "node:os" -import path from "node:path" -import { createSupervisor, healthy, serveCommand, supervisorFor, unmanaged, type Supervisor } from "./opencode-process" - -/** - * The supervisor is what makes the canvas's "restart the engine" button real, - * so the tests run an actual child process rather than a mock: the parts that - * break are process-tree kills and port hand-off, and neither shows up against - * a fake. - * - * The stand-in engine is a one-file Bun server that answers `/api/health` and - * writes its own boot count, so a restart is provable — a new process, not a - * survivor. - */ - -const started: Supervisor[] = [] -const scratch: string[] = [] - -afterEach(async () => { - for (const supervisor of started.splice(0)) await supervisor.stop().catch(() => undefined) - for (const dir of scratch.splice(0)) await fs.rm(dir, { recursive: true, force: true }) -}) - -async function fakeEngine(port: number) { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-engine-")) - scratch.push(dir) - const marker = path.join(dir, "boots.txt") - const script = path.join(dir, "engine.ts") - await fs.writeFile( - script, - ` -import fs from "node:fs" -fs.appendFileSync(${JSON.stringify(marker)}, String(process.pid) + "\\n") -Bun.serve({ port: ${port}, fetch: (request) => new URL(request.url).pathname === "/api/health" - ? Response.json({ ok: true }) - : new Response("no", { status: 404 }) }) -`, - ) - return { - dir, - script, - url: `http://127.0.0.1:${port}`, - async boots() { - const raw = await fs.readFile(marker, "utf8").catch(() => "") - return raw.split("\n").filter(Boolean) - }, - } -} - -/** A port nothing is listening on, found by binding and releasing one. */ -async function freePort() { - const server = Bun.serve({ port: 0, fetch: () => new Response("") }) - const port = server.port! - server.stop(true) - return port -} - -describe("serveCommand", () => { - test("runs the engine from source inside the repo", () => { - const spec = serveCommand({ repo: "/repo", url: "http://127.0.0.1:4096", hasSource: true, env: {} }) - expect(spec.command).toContain("packages/opencode") - expect(spec.command.at(-1)).toBe("4096") - expect(spec.cwd).toBe("/repo") - }) - - test("falls back to the packaged binary outside it", () => { - const spec = serveCommand({ repo: "/anywhere", url: "http://127.0.0.1:4097", hasSource: false, env: {} }) - expect(spec.command).toEqual(["opencode", "serve", "--port", "4097"]) - }) - - test("an explicit command wins, and is split into argv rather than shelled out", () => { - const spec = serveCommand({ - repo: "/repo", - url: "http://127.0.0.1:4096", - hasSource: true, - env: { OPENCODE_SERVE_COMMAND: "my-opencode serve --port 9999" }, - }) - expect(spec.command).toEqual(["my-opencode", "serve", "--port", "9999"]) - }) -}) - -describe("ownership", () => { - test("does not manage the engine unless asked", async () => { - const supervisor = await supervisorFor( - { command: ["opencode", "serve"], cwd: process.cwd(), url: "http://127.0.0.1:1" }, - {}, - ) - const status = await supervisor.status() - expect(status.managed).toBe(false) - expect(status.reason).toContain("FLOW_MANAGE_SERVER") - }) - - test("refuses to adopt an engine that is already serving the port", async () => { - const port = await freePort() - const engine = await fakeEngine(port) - const owner = createSupervisor({ command: ["bun", engine.script], cwd: engine.dir, url: engine.url }) - started.push(owner) - await owner.ensure() - - const second = await supervisorFor({ command: ["bun", engine.script], cwd: engine.dir, url: engine.url }, { - FLOW_MANAGE_SERVER: "1", - }) - const status = await second.status() - expect(status.managed).toBe(false) - expect(status.reason).toContain("already serving") - // …and it will not try: a second engine on one port is the failure mode - // this whole check exists to prevent. - expect(second.restart()).rejects.toThrow(/does not own/) - }) - - test("an unmanaged supervisor hands back the command instead of restarting", async () => { - const supervisor = unmanaged({ command: ["opencode", "serve", "--port", "4096"], cwd: ".", url: "http://127.0.0.1:1" }, "not ours") - expect((await supervisor.status()).command).toBe("opencode serve --port 4096") - expect(supervisor.restart()).rejects.toThrow(/opencode serve --port 4096/) - }) - - test("both supervisors report the directory the command has to run from", async () => { - // The UI prints `cd ` above the command because the from-source command - // is relative; without this it invented a checkout name. - const spec = { command: ["opencode", "serve", "--port", "4096"], cwd: "/repo/root", url: "http://127.0.0.1:1" } - expect((await unmanaged(spec, "not ours").status()).cwd).toBe("/repo/root") - // Nothing is spawned — `status()` only probes the (dead) url. - expect((await createSupervisor(spec).status()).cwd).toBe("/repo/root") - }) -}) - -describe("restart", () => { - test("replaces the process and comes back answering", async () => { - const port = await freePort() - const engine = await fakeEngine(port) - const supervisor = createSupervisor({ command: ["bun", engine.script], cwd: engine.dir, url: engine.url }) - started.push(supervisor) - - const first = await supervisor.ensure() - expect(first.running).toBe(true) - expect(await engine.boots()).toHaveLength(1) - - const second = await supervisor.restart() - expect(second.running).toBe(true) - expect(second.managed).toBe(true) - // A new boot, not the old process still holding the port. - const boots = await engine.boots() - expect(boots).toHaveLength(2) - expect(boots[0]).not.toBe(boots[1]) - expect(await healthy(engine.url)).toBe(true) - }, 60_000) - - test("stopping leaves the port free", async () => { - const port = await freePort() - const engine = await fakeEngine(port) - const supervisor = createSupervisor({ command: ["bun", engine.script], cwd: engine.dir, url: engine.url }) - started.push(supervisor) - - await supervisor.ensure() - await supervisor.stop() - expect(await healthy(engine.url)).toBe(false) - }, 60_000) - - test("a command that cannot start fails fast instead of hanging", async () => { - const port = await freePort() - const supervisor = createSupervisor({ - command: ["bun", "--this-flag-does-not-exist"], - cwd: process.cwd(), - url: `http://127.0.0.1:${port}`, - }) - started.push(supervisor) - expect(supervisor.ensure()).rejects.toThrow() - }, 30_000) -}) diff --git a/packages/flow/lib/opencode-process.ts b/packages/flow/lib/opencode-process.ts deleted file mode 100644 index 8bb061a157..0000000000 --- a/packages/flow/lib/opencode-process.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { spawn, type ChildProcess } from "node:child_process" - -/** - * Owns the `opencode serve` process, so the canvas can restart it. - * - * The server reads a project's config, agents, skills and MCP servers **once, - * at boot**. Everything OpenFlow writes — a merged agent block, a new skill, an - * MCP entry — is therefore invisible until the engine is restarted, and every - * one of those flows currently ends in a note telling the user to go and do it - * in a terminal. There is no shutdown route on the server (checked: nothing - * under `packages/server/src/handlers`), so the only thing that can restart it - * is whoever holds the process handle. That is this. - * - * Ownership is opt-in and never assumed: a host that did not spawn the engine - * reports `managed: false` and the UI falls back to showing the command. The - * one thing this must never do is start a second engine on a port that already - * has one — see `supervisorFor`. - */ - -export type ServeCommand = { - /** argv, already split. Never a shell string — nothing here goes through a shell. */ - command: string[] - cwd: string - /** Base URL the engine is expected to answer on, e.g. `http://127.0.0.1:4096`. */ - url: string -} - -export type ServeStatus = { - /** True only when this process spawned the engine and can restart it. */ - managed: boolean - /** True when the engine answers `/api/health`. */ - running: boolean - url: string - /** Printable form of the command, for the unmanaged fallback in the UI. */ - command: string - /** - * Directory the command has to run from — the from-source command is relative - * (`--cwd packages/opencode`), so the UI prints `cd ` above it rather than - * guessing a checkout name. Optional only because stand-in statuses in tests - * predate it; both supervisors always set it. - */ - cwd?: string - pid?: number - startedAt?: number - /** Why the last start or restart failed, if it did. */ - error?: string - /** Why this host is not managing the engine, when it is not. */ - reason?: string -} - -export type Supervisor = { - status: () => Promise - /** Starts the engine if this host owns it and it is not already up. */ - ensure: () => Promise - restart: () => Promise - stop: () => Promise -} - -const START_TIMEOUT = 90_000 -const STOP_TIMEOUT = 15_000 -const POLL = 400 - -/** - * Whether the engine answers. - * - * Bounded on purpose: a probe against a process that has just been killed can - * land on a pooled connection that never answers and never errors, and an - * unbounded probe would hang the restart it is supposed to be supervising. - * `connection: close` keeps each probe on its own socket for the same reason. - */ -export async function healthy(url: string, timeout = 5_000) { - return await fetch(new URL("/api/health", url), { - signal: AbortSignal.timeout(timeout), - headers: { connection: "close" }, - }) - .then((response) => response.ok) - .catch(() => false) -} - -function printable(command: string[]) { - return command.map((part) => (part.includes(" ") ? JSON.stringify(part) : part)).join(" ") -} - -function wait(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -/** - * Kills the engine and everything it spawned. - * - * `bun run …` execs a child that is the process actually holding the port, so - * killing only the direct child leaves the port occupied and the next start - * fails with EADDRINUSE. POSIX gets its own process group (`detached: true`) - * and a group signal; Windows gets `taskkill /T`, which is the only reliable - * way to take a tree there. - */ -async function killTree(child: ChildProcess) { - const pid = child.pid - if (!pid || child.exitCode !== null || child.signalCode !== null) return - if (process.platform === "win32") { - await new Promise((resolve) => { - const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { windowsHide: true }) - killer.on("exit", () => resolve()) - killer.on("error", () => resolve()) - }) - return - } - try { - process.kill(-pid, "SIGTERM") - } catch { - try { - child.kill("SIGTERM") - } catch {} - } -} - -/** - * A supervisor that owns nothing. - * - * The honest answer for a host that proxies to an engine somebody else - * started — the launcher script, another terminal, a preview manager. It still - * reports whether the engine is up and what command would start it, which is - * everything the UI's fallback needs. - */ -export function unmanaged(spec: ServeCommand, reason: string): Supervisor { - const status = async (): Promise => ({ - managed: false, - running: await healthy(spec.url), - url: spec.url, - command: printable(spec.command), - cwd: spec.cwd, - reason, - }) - return { - status, - ensure: status, - async restart() { - throw new Error( - `this OpenFlow host does not own \`opencode serve\` (${reason}) — restart it where it was started, with: ${printable(spec.command)}`, - ) - }, - async stop() {}, - } -} - -export function createSupervisor(spec: ServeCommand, log: (line: string) => void = () => {}): Supervisor { - let child: ChildProcess | undefined - let startedAt: number | undefined - let lastError: string | undefined - /** Serialises start/stop so two clicks cannot race into two engines. */ - let queue: Promise = Promise.resolve() - - function serialise(work: () => Promise): Promise { - const next = queue.then(work, work) - queue = next.catch(() => undefined) - return next - } - - async function status(): Promise { - return { - managed: true, - running: await healthy(spec.url), - url: spec.url, - command: printable(spec.command), - cwd: spec.cwd, - pid: child?.pid, - startedAt, - error: lastError, - } - } - - async function spawnEngine() { - lastError = undefined - const [file, ...args] = spec.command - const next = spawn(file, args, { - cwd: spec.cwd, - env: process.env, - stdio: "inherit", - // POSIX: own process group, so `killTree` can take the whole thing. - detached: process.platform !== "win32", - windowsHide: true, - }) - child = next - startedAt = Date.now() - next.on("error", (error) => { - lastError = error.message - }) - next.on("exit", (code, signal) => { - if (child === next) child = undefined - if (code && code !== 0) lastError = `engine exited with code ${code}${signal ? ` (${signal})` : ""}` - }) - - const deadline = Date.now() + START_TIMEOUT - while (Date.now() < deadline) { - if (await healthy(spec.url)) return - // A crash during boot must fail now rather than after the full timeout — - // a bad command or a busy port is the usual cause and both are instant. - if (next.exitCode !== null || next.signalCode !== null) { - throw new Error(lastError ?? `engine exited during startup (code ${next.exitCode})`) - } - await wait(POLL) - } - throw new Error(`engine did not answer ${spec.url}/api/health within ${START_TIMEOUT / 1000}s`) - } - - async function stopEngine() { - const current = child - if (!current) return - await killTree(current) - const deadline = Date.now() + STOP_TIMEOUT - // Wait for the port to actually go quiet: respawning while the old process - // still holds it is how a restart turns into a dead engine. - while (Date.now() < deadline) { - if (current.exitCode !== null || current.signalCode !== null) { - if (!(await healthy(spec.url))) break - } - await wait(POLL) - } - if (child === current) child = undefined - } - - return { - status, - ensure: () => - serialise(async () => { - if (child && (await healthy(spec.url))) return status() - if (!child && (await healthy(spec.url))) { - // Something else is already serving this port. Never start a second. - throw new Error(`something is already serving ${spec.url} — not starting a second engine`) - } - log(`starting engine: ${printable(spec.command)}`) - await spawnEngine() - return status() - }), - restart: () => - serialise(async () => { - log("restarting engine") - await stopEngine() - if (await healthy(spec.url)) { - throw new Error( - `${spec.url} is still answering after the engine was stopped — something else owns that port`, - ) - } - await spawnEngine() - log("engine restarted") - return status() - }), - stop: () => serialise(stopEngine), - } -} - -/** - * Picks a supervisor for this host. - * - * Management is opt-in (`FLOW_MANAGE_SERVER=1`) because the shipped launchers - * and most people start the engine themselves, and adopting a process we did - * not spawn is not something a browser click should be able to do. Even when - * asked, an engine already answering the port is left alone: the button then - * reports the command instead of restarting a process it has no handle on. - */ -export async function supervisorFor( - spec: ServeCommand, - env: Record = process.env, - log?: (line: string) => void, -): Promise { - if (env.FLOW_MANAGE_SERVER !== "1") { - return unmanaged(spec, "it was started outside OpenFlow — set FLOW_MANAGE_SERVER=1 to let OpenFlow own it") - } - if (await healthy(spec.url)) { - return unmanaged(spec, `an engine was already serving ${spec.url} before this host started`) - } - return createSupervisor(spec, log) -} - -/** - * The command that starts the engine for this checkout. - * - * Inside the repo the engine is run from source, the same way the launcher - * scripts and `.claude/launch.json` do it. Anywhere else the packaged `opencode` - * binary on PATH is the only thing that could work. `OPENCODE_SERVE_COMMAND` - * overrides both — space-separated argv, no shell. - */ -export function serveCommand(input: { - repo: string - url: string - hasSource: boolean - env?: Record -}): ServeCommand { - const env = input.env ?? process.env - const port = new URL(input.url).port || "4096" - const override = env.OPENCODE_SERVE_COMMAND?.trim() - if (override) return { command: override.split(/\s+/), cwd: input.repo, url: input.url } - if (input.hasSource) { - return { - command: ["bun", "run", "--cwd", "packages/opencode", "--conditions=browser", "src/index.ts", "serve", "--port", port], - cwd: input.repo, - url: input.url, - } - } - return { command: ["opencode", "serve", "--port", port], cwd: input.repo, url: input.url } -} diff --git a/packages/flow/lib/repackage.test.ts b/packages/flow/lib/repackage.test.ts deleted file mode 100644 index f42815d1a5..0000000000 --- a/packages/flow/lib/repackage.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, test } from "bun:test" -import path from "node:path" -import { COMPATIBLE_PROFILES, globalConfigCandidates, isV1Config, repackage, repackaged } from "./repackage" - -describe("globalConfigCandidates", () => { - test("follows XDG_CONFIG_HOME when it is set", () => { - expect(globalConfigCandidates({ XDG_CONFIG_HOME: "/cfg" }, "/home/x")[0]).toBe( - path.join("/cfg", "opencode", "opencode.json"), - ) - }) - - test("falls back to ~/.config, and offers .jsonc second", () => { - expect(globalConfigCandidates({}, "/home/x")).toEqual([ - path.join("/home/x", ".config", "opencode", "opencode.json"), - path.join("/home/x", ".config", "opencode", "opencode.jsonc"), - ]) - }) -}) - -describe("repackage", () => { - test("writes the v2 shape into a config with no v1 key", () => { - const { value, changed } = repackage({ $schema: "x", model: "opencode/y" }, ["openrouter"]) - expect(changed).toEqual(["openrouter"]) - expect(value.providers.openrouter.api).toEqual({ - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: COMPATIBLE_PROFILES.openrouter, - }) - expect(value.provider).toBeUndefined() - }) - - // One v1 key sends the whole file through the v1 migration, where `providers` - // is not read at all — the override has to follow the file's dialect. - test("writes the v1 shape into a config that already carries a v1 key", () => { - const { value } = repackage({ plugin: ["./p.js"] }, ["groq"]) - expect(value.provider.groq).toEqual({ npm: "@ai-sdk/openai-compatible", api: COMPATIBLE_PROFILES.groq }) - expect(value.providers).toBeUndefined() - }) - - test("keeps the rest of an existing provider entry", () => { - const { value } = repackage({ provider: { groq: { name: "Groq", models: { a: {} } } } }, ["groq"]) - expect(value.provider.groq.name).toBe("Groq") - expect(value.provider.groq.models).toEqual({ a: {} }) - expect(value.provider.groq.npm).toBe("@ai-sdk/openai-compatible") - }) - - test("reports nothing changed when the override is already there", () => { - const first = repackage({}, ["openrouter"]) - expect(repackage(first.value, ["openrouter"]).changed).toEqual([]) - }) - - test("ignores a provider the runner has no OpenAI-compatible profile for", () => { - expect(repackage({}, ["google", "openrouter"]).changed).toEqual(["openrouter"]) - }) - - test("does not mutate the config it was given", () => { - const config = { $schema: "x" } - repackage(config, ["groq"]) - expect(config).toEqual({ $schema: "x" }) - }) -}) - -describe("repackaged", () => { - test("reads both dialects, and rejects a different package or url", () => { - expect(repackaged({ provider: { groq: { npm: "@ai-sdk/openai-compatible", api: COMPATIBLE_PROFILES.groq } } })).toEqual([ - "groq", - ]) - expect(repackaged(repackage({}, ["xai"]).value)).toEqual(["xai"]) - expect(repackaged({ provider: { groq: { npm: "@ai-sdk/groq" } } })).toEqual([]) - expect(repackaged({ provider: { groq: { npm: "@ai-sdk/openai-compatible", api: "https://example.test" } } })).toEqual( - [], - ) - expect(repackaged({})).toEqual([]) - }) -}) - -describe("isV1Config", () => { - test("one v1 key claims the whole file", () => { - expect(isV1Config({ model: "x", plugin: [] })).toBe(true) - expect(isV1Config({ model: "x", providers: {} })).toBe(false) - expect(isV1Config(undefined)).toBe(false) - }) -}) diff --git a/packages/flow/lib/repackage.ts b/packages/flow/lib/repackage.ts deleted file mode 100644 index b71264cc4d..0000000000 --- a/packages/flow/lib/repackage.ts +++ /dev/null @@ -1,137 +0,0 @@ -import os from "node:os" -import path from "node:path" - -/** - * Makes OpenRouter, Groq and their OpenAI-compatible siblings dispatchable on - * an engine whose runner cannot route their packages. - * - * `core/src/session/runner/model.ts` (`fromCatalogModel`) routes three API - * shapes and nothing else: `@ai-sdk/openai`, `@ai-sdk/anthropic`, and - * `@ai-sdk/openai-compatible` *with a url*. A model whose catalog entry names - * `@openrouter/ai-sdk-provider` or `@ai-sdk/groq` therefore fails the moment a - * node dispatches: - * - * SessionRunnerModel.UnsupportedApiError: Unsupported API for - * groq/llama-3.3-70b-versatile: aisdk:@ai-sdk/groq - * - * Upstream's fix routes those by provider id through - * `llm/src/providers/openai-compatible-profile.ts`, but this fork's vendored - * runner does not consult that table. The fix that needs no engine change is to - * repackage the provider in config: point it at `@ai-sdk/openai-compatible` and - * give it the base URL the profile table already publishes. Stored credentials - * still resolve, because the provider id is unchanged. - * - * It has to be the *global* config. A session's location is the engine's cwd, - * never `OPENFLOW_PROJECT` (see FLOW.md), so a project `opencode.json` reaches - * the catalog reads the browser makes and never the drain that runs the model. - * The global config is loaded for both. - * - * Cost of the swap: a provider plugin that keys off the old package stops - * applying — for OpenRouter that is its `HTTP-Referer`/`X-Title` headers and - * its disabling of the broken `gpt-5-chat` aliases. - */ - -/** - * Provider id to base URL, copied verbatim from upstream's - * `openai-compatible-profile.ts`. That table is upstream's own statement about - * which providers speak OpenAI chat and at which URL, so it is the list to copy - * from rather than a local one to grow. - */ -export const COMPATIBLE_PROFILES: Record = { - baseten: "https://inference.baseten.co/v1", - cerebras: "https://api.cerebras.ai/v1", - deepinfra: "https://api.deepinfra.com/v1/openai", - deepseek: "https://api.deepseek.com/v1", - fireworks: "https://api.fireworks.ai/inference/v1", - groq: "https://api.groq.com/openai/v1", - openrouter: "https://openrouter.ai/api/v1", - togetherai: "https://api.together.xyz/v1", - xai: "https://api.x.ai/v1", -} - -export const COMPATIBLE_PACKAGE = "@ai-sdk/openai-compatible" - -/** - * Mirrors `Global.Path.config` (xdg-basedir), the directory opencode loads - * before any project file. `xdg-basedir` falls back to `~/.config` on every - * platform, Windows included, so this is one path and not a per-OS branch. - * - * Both names opencode accepts are returned, in the order it reads them, so a - * caller can prefer the file that already exists. - */ -export function globalConfigCandidates(env: Record = process.env, home = os.homedir()) { - const config = env.XDG_CONFIG_HOME || path.join(home, ".config") - return [path.join(config, "opencode", "opencode.json"), path.join(config, "opencode", "opencode.jsonc")] -} - -/** - * Keys that make `ConfigMigrateV1.isV1` claim a file, copied from - * `core/src/v1/config/migrate.ts`. One of them anywhere in the file sends the - * *whole* config through the v1 migration, where `providers` is not a key and - * `provider` is — so the shape this writes has to follow the file it is - * writing into, or the override lands somewhere the loader never looks. - */ -const V1_KEYS = new Set([ - "logLevel", - "server", - "command", - "reference", - "snapshot", - "plugin", - "autoshare", - "disabled_providers", - "enabled_providers", - "small_model", - "mode", - "agent", - "provider", - "permission", - "tools", - "attachment", - "layout", -]) - -export function isV1Config(config: unknown) { - if (typeof config !== "object" || config === null || Array.isArray(config)) return false - return Object.keys(config).some((key) => V1_KEYS.has(key)) -} - -/** Provider ids this config already repackages, in table order. */ -export function repackaged(config: any): string[] { - return Object.keys(COMPATIBLE_PROFILES).filter((id) => { - const v1 = config?.provider?.[id] - if (v1?.npm === COMPATIBLE_PACKAGE && v1?.api === COMPATIBLE_PROFILES[id]) return true - const v2 = config?.providers?.[id]?.api - return v2?.type === "aisdk" && v2?.package === COMPATIBLE_PACKAGE && v2?.url === COMPATIBLE_PROFILES[id] - }) -} - -/** - * Adds the override for each id, in whichever dialect the file is already - * written in, and reports which entries actually changed. An id already - * repackaged is not listed, so a caller can skip the write — and the backup — - * entirely. - * - * Everything else on the provider entry survives: a hand-written `models` map - * or `options` block is merged around, not replaced. - */ -export function repackage(config: any, ids: string[]) { - const value = { ...config } - const changed = ids.filter((id) => COMPATIBLE_PROFILES[id] && !repackaged(config).includes(id)) - if (!changed.length) return { value, changed } - - if (isV1Config(config)) { - value.provider = { ...value.provider } - for (const id of changed) - value.provider[id] = { ...value.provider[id], npm: COMPATIBLE_PACKAGE, api: COMPATIBLE_PROFILES[id] } - return { value, changed } - } - - value.providers = { ...value.providers } - for (const id of changed) - value.providers[id] = { - ...value.providers[id], - api: { type: "aisdk", package: COMPATIBLE_PACKAGE, url: COMPATIBLE_PROFILES[id] }, - } - return { value, changed } -} diff --git a/packages/flow/lib/store.test.ts b/packages/flow/lib/store.test.ts index a18164ada4..ab7e76fe0c 100644 --- a/packages/flow/lib/store.test.ts +++ b/packages/flow/lib/store.test.ts @@ -3,21 +3,23 @@ import fs from "node:fs/promises" import os from "node:os" import path from "node:path" import { recallPipeline, recallProject, statePath } from "./last-session" -import type { ServeStatus, Supervisor } from "./opencode-process" -import { backupConfig, browseDirectory, flowPaths, handleFlow, slug, type FlowPaths } from "./store" +import { browseDirectory, flowPaths, handleFlow, slug, type FlowPaths } from "./store" /** * This is the surface that writes to a user's real repository — pipelines, run - * logs, and a merge into their opencode.json — so it is tested against a real - * temporary directory rather than a mocked filesystem. + * logs — and to the Omp profile's MCP config and skills, so it is tested + * against a real temporary directory rather than a mocked filesystem. */ let dir: string +let profile: string let paths: FlowPaths beforeEach(async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), "openflow-store-")) paths = flowPaths(dir) + // Not created: a profile that has never been written to has no directory yet. + profile = path.join(dir, "omp-profile", "agent") // A project switch remembers the folder for the next launch; without this // the suite would overwrite the developer's own `~/.openflow/state.json`. process.env.OPENFLOW_STATE_DIR = path.join(dir, "state") @@ -30,37 +32,21 @@ afterEach(async () => { const read = (file: string) => fs.readFile(file, "utf8") +/** `profile: null` is a host that does not know the Omp profile's directory. */ function call( method: string, route: string, - options: { body?: unknown; search?: string; serve?: Supervisor } = {}, + options: { body?: unknown; search?: string; profile?: string | null } = {}, ) { return handleFlow(paths, { method, path: route, search: new URLSearchParams(options.search ?? ""), json: async () => options.body ?? {}, - serve: options.serve, + profileDir: async () => (options.profile === undefined ? profile : (options.profile ?? undefined)), }) } -/** Stand-in for the engine's process handle. */ -function supervisor(status: Partial, onRestart?: () => Promise): Supervisor { - const full = (): ServeStatus => ({ - managed: false, - running: true, - url: "http://127.0.0.1:4096", - command: "opencode serve --port 4096", - ...status, - }) - return { - status: async () => full(), - ensure: async () => full(), - restart: onRestart ?? (async () => full()), - stop: async () => {}, - } -} - const graph = { id: "p1", name: "feature-build", nodes: [{ id: "n1" }], edges: [] } describe("slug", () => { @@ -421,366 +407,70 @@ describe("runs", () => { }) }) -describe("agents", () => { - const agent = { agent: { "feature-build-planner": { mode: "primary", prompt: "plan" } } } - - /** - * A merge lands in the *global* config, not the project's: a session's - * location is the engine's cwd, so agents written into the target project are - * not there when the run drains, and the run fails with "the server does not - * know an agent named …". `XDG_CONFIG_HOME` keeps that out of the developer's - * own config. - */ - let global: string - - beforeEach(async () => { - process.env.XDG_CONFIG_HOME = path.join(dir, "xdg") - global = path.join(dir, "xdg", "opencode", "opencode.json") - await fs.mkdir(path.dirname(global), { recursive: true }) - }) - - afterEach(() => { - delete process.env.XDG_CONFIG_HOME - }) - - test("writes only the generated block unless a merge is asked for", async () => { - const result = await call("POST", "/flow/api/pipelines/feature-build/agents", { body: agent }) - - expect(result!.body).toMatchObject({ merged: false }) - const written = JSON.parse(await read(path.join(paths.generated, "feature-build.opencode.json"))) - expect(written.agent).toEqual(agent.agent) - await expect(read(global)).rejects.toThrow() - }) - - test("merge creates the config when the project has none", async () => { - const result = await call("POST", "/flow/api/pipelines/feature-build/agents", { body: agent, search: "merge=1" }) - - expect(result!.body).toMatchObject({ merged: true, backup: undefined }) - const config = JSON.parse(await read(global)) - expect(config.agent).toEqual(agent.agent) - }) - - test("merge keeps what the project already had", async () => { - await fs.writeFile(global, JSON.stringify({ model: "opencode/x", agent: { mine: {} } })) - - await call("POST", "/flow/api/pipelines/feature-build/agents", { body: agent, search: "merge=1" }) - - const config = JSON.parse(await read(global)) - expect(config.model).toBe("opencode/x") - expect(Object.keys(config.agent).sort()).toEqual(["feature-build-planner", "mine"]) - }) - - test("a second merge does not overwrite the original backup", async () => { - const target = global - await fs.writeFile(target, JSON.stringify({ agent: { mine: {} } })) - const changed = { agent: { "feature-build-planner": { mode: "primary", prompt: "replan" } } } - - await call("POST", "/flow/api/pipelines/feature-build/agents", { body: agent, search: "merge=1" }) - const second = await call("POST", "/flow/api/pipelines/feature-build/agents", { body: changed, search: "merge=1" }) - - expect(second!.body).toMatchObject({ backup: `${target}.prev.bak` }) - expect(JSON.parse(await read(`${target}.bak`)).agent).toEqual({ mine: {} }) - }) - - test("a merge that changes nothing does not touch the config or leave a backup", async () => { - const target = global - await fs.writeFile(target, JSON.stringify({ agent: { mine: {} } })) - - await call("POST", "/flow/api/pipelines/feature-build/agents", { body: agent, search: "merge=1" }) - const before = await read(target) - const second = await call("POST", "/flow/api/pipelines/feature-build/agents", { body: agent, search: "merge=1" }) - - expect(second!.body).toMatchObject({ merged: false, unchanged: true }) - expect(second!.body).not.toHaveProperty("backup") - expect(await read(target)).toBe(before) - // The first merge's `.bak`, not a second `.prev.bak`, is the only backup. - await expect(read(`${target}.prev.bak`)).rejects.toThrow() - }) - - test("a merge drops the agents this pipeline no longer generates, and nothing else", async () => { - const marked = (id: string, role: string, pipeline = "feature-build") => ({ - description: `OpenFlow node ${id} (${role}) of pipeline ${pipeline}`, - }) - await fs.writeFile( - global, - JSON.stringify({ - agent: { - "feature-build-planner": marked("n1", "planner"), - // Renamed away: generated for this pipeline, no longer in the block. - "feature-build-scout": marked("n0", "scout"), - // Another pipeline's, and a hand-written one that merely looks generated. - "release-notes-writer": marked("n9", "writer", "release-notes"), - "feature-build-mine": {}, - }, - }), - ) - - await call("POST", "/flow/api/pipelines/feature-build/agents", { - body: { agent: { "feature-build-planner": marked("n1", "planner") } }, - search: "merge=1", - }) - - const config = JSON.parse(await read(global)) - expect(Object.keys(config.agent).sort()).toEqual([ - "feature-build-mine", - "feature-build-planner", - "release-notes-writer", - ]) - }) - - test("an unchanged merge with no config to merge into names the generated file", async () => { - const result = await call("POST", "/flow/api/pipelines/feature-build/agents", { - body: { agent: {} }, - search: "merge=1", - }) - - expect(result!.body).toMatchObject({ - path: path.join(paths.generated, "feature-build.opencode.json"), - unchanged: true, - }) - await expect(read(global)).rejects.toThrow() - }) - - test("refuses to merge into a config it cannot parse", async () => { - await fs.writeFile(global, "{not json") - - const result = await call("POST", "/flow/api/pipelines/feature-build/agents", { body: agent, search: "merge=1" }) - - expect(result!.body).toMatchObject({ merged: false, error: expect.stringContaining("not valid JSON") }) - expect(await read(global)).toBe("{not json") - }) - - test("cleans up after a pipeline whose name needed slugging", async () => { - // The route carries the slug (`my-flow`); the descriptions carry the raw - // name (`My Flow`). Matching the slug against them recognised nothing, so - // no stale agent was ever dropped and every rename added a generation. - const marked = (id: string, role: string) => ({ - description: `OpenFlow node ${id} (${role}) of pipeline My Flow`, - }) - await fs.writeFile( - global, - JSON.stringify({ agent: { "my-flow-planner": marked("n1", "planner"), "my-flow-scout": marked("n0", "scout") } }), - ) - - await call("POST", `/flow/api/pipelines/${encodeURIComponent("My Flow")}/agents`, { - body: { agent: { "my-flow-planner": marked("n1", "planner") } }, - search: "merge=1", - }) - - expect(Object.keys(JSON.parse(await read(global)).agent)).toEqual(["my-flow-planner"]) - }) - - test("takes the raw name from the body when a caller sends one", async () => { - // The block here carries no description to read the raw name back off, so - // only `pipeline` in the body can supply it. - await fs.writeFile( - global, - JSON.stringify({ agent: { "my-flow-scout": { description: "OpenFlow node n0 (scout) of pipeline My Flow" } } }), - ) - - await call("POST", "/flow/api/pipelines/my-flow/agents", { - body: { pipeline: "My Flow", agent: { "my-flow-planner": { mode: "primary" } } }, - search: "merge=1", - }) - - expect(Object.keys(JSON.parse(await read(global)).agent)).toEqual(["my-flow-planner"]) - }) -}) - -describe("opencode's own config dialect", () => { - const configPath = () => path.join(dir, "opencode.json") - - // opencode parses opencode.json with jsonc-parser and allowTrailingComma, so - // a commented config is a *valid* one. Reading it as strict JSON failed every - // config write: the agent merge, the MCP panel, and skill registration. - test("reads a config with comments and a trailing comma", async () => { - await fs.writeFile( - configPath(), - `{ - // the search server - "mcp": { - /* local */ - "context7": { "type": "local", "command": ["a"] }, - }, -} -`, - ) - - const rows = (await call("GET", "/mcp"))?.body as any[] - expect(rows.map((row) => row.name)).toEqual(["context7"]) - expect(rows[0]).toMatchObject({ type: "local", command: ["a"] }) - }) - - test("refuses to rewrite a commented config, and says so rather than crying invalid JSON", async () => { - const source = `{\n // keep me\n "model": "opencode/x"\n}\n` - await fs.writeFile(configPath(), source) - - const response = await call("PUT", "/mcp/one", { body: { type: "local", command: ["a"] } }) - - // Re-serialising would silently delete the comment, so the write is - // refused — but the reason has to name the real problem. - expect(response?.status).toBe(400) - expect((response?.body as any).error).toContain("has comments") - expect(await read(configPath())).toBe(source) - }) - - test("a trailing comma alone is no reason to refuse a write", async () => { - await fs.writeFile(configPath(), `{\n "model": "opencode/x",\n}\n`) - - const response = await call("PUT", "/mcp/one", { body: { type: "local", command: ["a"] } }) - - expect(response?.status).toBe(200) - const config = JSON.parse(await read(configPath())) - expect(config.model).toBe("opencode/x") - expect(config.mcp.one).toMatchObject({ type: "local" }) - }) - - test("a comma or a comment marker inside a value is left alone", async () => { - const model = 'a, } b // c /* d */ "' - await fs.writeFile(configPath(), JSON.stringify({ model, mcp: {} })) - - await call("PUT", "/mcp/one", { body: { type: "local", command: ["a"] } }) - - expect(JSON.parse(await read(configPath())).model).toBe(model) - }) - - test("a skill lands unregistered rather than silently, when the config will not take the source", async () => { - // registerSkillSource runs before the SKILL.md write for this reason: a - // skill registered nowhere is one the agent can never see. - await fs.writeFile(configPath(), `{\n // keep me\n}\n`) - - const result = await call("PUT", "/flow/api/skills/summarize", { body: { name: "Summarize", content: "x" } }) - - expect(result!.body).toMatchObject({ registered: false, error: expect.stringContaining("has comments") }) - await expect(read(path.join(paths.skills, "summarize", "SKILL.md"))).resolves.toContain("name: Summarize") - }) -}) - -describe("concurrent config writes", () => { - // Clicking Run (which merges agents) while an MCP server or a skill is being - // saved meant both read the same pre-state and the later write discarded the - // earlier one. Two tabs, or the vite host beside `bun start`, make it routine. - test("an mcp save and a skill registration both land", async () => { - await Promise.all([ - call("PUT", "/mcp/one", { body: { type: "local", command: ["a"] } }), - call("PUT", "/flow/api/skills/summarize", { body: { name: "Summarize", content: "x" } }), - ]) - - const config = JSON.parse(await read(path.join(dir, "opencode.json"))) - expect(config.mcp.one).toMatchObject({ type: "local" }) - expect(config.skills).toEqual({ paths: ["./.openflow/skills"] }) - }) - - test("an agent merge and an mcp save do not discard each other", async () => { - // They no longer write the same file at all — agents go to the global - // config, because that is the only one a run reads, and MCP servers stay - // with the project. Both still have to land. - process.env.XDG_CONFIG_HOME = path.join(dir, "xdg") - await fs.mkdir(path.join(dir, "xdg", "opencode"), { recursive: true }) - try { - await Promise.all([ - call("POST", "/flow/api/pipelines/feature-build/agents", { - body: { agent: { "feature-build-planner": { mode: "primary" } } }, - search: "merge=1", - }), - call("PUT", "/mcp/two", { body: { type: "local", command: ["b"] } }), - ]) - - expect(JSON.parse(await read(path.join(dir, "xdg", "opencode", "opencode.json"))).agent).toHaveProperty( - "feature-build-planner", - ) - expect(JSON.parse(await read(path.join(dir, "opencode.json"))).mcp.two).toMatchObject({ type: "local" }) - } finally { - delete process.env.XDG_CONFIG_HOME - } - }) - - test("two mcp saves at once both survive", async () => { - await Promise.all([ - call("PUT", "/mcp/alpha", { body: { type: "local", command: ["a"] } }), - call("PUT", "/mcp/beta", { body: { type: "remote", url: "https://b" } }), - ]) - - expect(Object.keys(JSON.parse(await read(path.join(dir, "opencode.json"))).mcp).sort()).toEqual(["alpha", "beta"]) - }) -}) - describe("skills", () => { const skill = { name: "Summarize", description: "Condense text", content: "# Summarize\n\nBe brief." } + const skillsDir = () => path.join(profile, "skills") + const legacyFile = (name: string) => path.join(paths.skills, name, "SKILL.md") - test("writes SKILL.md with frontmatter and registers the source once", async () => { + async function writeLegacy(name: string, description = "Old one") { + await fs.mkdir(path.join(paths.skills, name), { recursive: true }) + await fs.writeFile(legacyFile(name), `---\nname: ${name}\ndescription: ${description}\n---\n\nold body\n`) + } + + test("writes SKILL.md with frontmatter into the profile's skills directory", async () => { const result = await call("PUT", "/flow/api/skills/summarize", { body: skill }) + const file = path.join(skillsDir(), "summarize", "SKILL.md") + + expect(result!.body).toEqual({ name: "summarize", path: file }) + expect(await read(file)).toBe("---\nname: Summarize\ndescription: Condense text\n---\n# Summarize\n\nBe brief.\n") + }) - expect(result!.body).toMatchObject({ name: "summarize", registered: true }) - const md = await read(path.join(paths.skills, "summarize", "SKILL.md")) - expect(md).toContain("name: Summarize") - expect(md).toContain("description: Condense text") - expect(md.endsWith("Be brief.\n")).toBe(true) + test("writes nothing into the project: no config, no .openflow/skills", async () => { + await call("PUT", "/flow/api/skills/summarize", { body: skill }) - const config = JSON.parse(await read(path.join(dir, "opencode.json"))) - expect(config.skills).toEqual({ paths: ["./.openflow/skills"] }) + expect(await fs.readdir(dir)).not.toContain("opencode.json") + expect(await fs.readdir(dir)).not.toContain(".openflow") }) test("reads a skill back split into frontmatter and body", async () => { await call("PUT", "/flow/api/skills/summarize", { body: skill }) const loaded = (await call("GET", "/flow/api/skills/summarize"))!.body as any expect(loaded).toMatchObject({ name: "Summarize", folder: "summarize", description: "Condense text" }) - expect(loaded.content.trim()).toBe("# Summarize\n\nBe brief.") + expect(loaded.content).toContain("Be brief.") + expect(loaded.legacy).toBeUndefined() }) test("an edit round-trip keeps the frontmatter name instead of the folder slug", async () => { await call("PUT", "/flow/api/skills/summarize", { body: skill }) const loaded = (await call("GET", "/flow/api/skills/summarize"))!.body as any - await call("PUT", `/flow/api/skills/${loaded.folder}`, { body: { ...skill, name: loaded.name } }) - - expect(await read(path.join(paths.skills, "summarize", "SKILL.md"))).toContain("name: Summarize") + await call("PUT", "/flow/api/skills/summarize", { body: { ...loaded, content: "# Edited" } }) + expect(((await call("GET", "/flow/api/skills/summarize"))!.body as any).name).toBe("Summarize") }) - test("registering the source is idempotent and never duplicates it", async () => { - await call("PUT", "/flow/api/skills/one", { body: skill }) - const second = await call("PUT", "/flow/api/skills/two", { body: { ...skill, name: "Two" } }) - - expect(second!.body).toMatchObject({ registered: false }) - const config = JSON.parse(await read(path.join(dir, "opencode.json"))) - expect(config.skills).toEqual({ paths: ["./.openflow/skills"] }) - }) - - test("keeps an existing config and backs it up when first registering", async () => { - await fs.writeFile(path.join(dir, "opencode.json"), JSON.stringify({ model: "opencode/x" })) - const result = await call("PUT", "/flow/api/skills/summarize", { body: skill }) - - expect(result!.body).toMatchObject({ registered: true, backup: path.join(dir, "opencode.json") + ".bak" }) - const config = JSON.parse(await read(path.join(dir, "opencode.json"))) - expect(config.model).toBe("opencode/x") - expect(config.skills).toEqual({ paths: ["./.openflow/skills"] }) - }) - - test("repairs a bare skills array left by an older build", async () => { - await fs.writeFile(path.join(dir, "opencode.json"), JSON.stringify({ skills: ["./.openflow/skills", "./other"] })) - - await call("PUT", "/flow/api/skills/summarize", { body: skill }) + test("lists newest first, skipping folders without a SKILL.md and stray files", async () => { + await call("PUT", "/flow/api/skills/alpha", { body: skill }) + await call("PUT", "/flow/api/skills/beta", { body: { ...skill, name: "Beta" } }) + await fs.mkdir(path.join(skillsDir(), "empty"), { recursive: true }) + await fs.writeFile(path.join(skillsDir(), "stray.txt"), "x") + await fs.utimes(path.join(skillsDir(), "alpha", "SKILL.md"), 2000, 2000) + await fs.utimes(path.join(skillsDir(), "beta", "SKILL.md"), 1000, 1000) - const config = JSON.parse(await read(path.join(dir, "opencode.json"))) - expect(config.skills).toEqual({ paths: ["./.openflow/skills", "./other"] }) + const list = (await call("GET", "/flow/api/skills"))!.body as any[] + expect(list.map((entry) => entry.name)).toEqual(["alpha", "beta"]) + expect(list[0]).toMatchObject({ description: "Condense text" }) + expect(list.some((entry) => "legacy" in entry)).toBe(false) }) - test("keeps skills.urls when adding the path", async () => { - await fs.writeFile(path.join(dir, "opencode.json"), JSON.stringify({ skills: { urls: ["https://x/skills/"] } })) - - await call("PUT", "/flow/api/skills/summarize", { body: skill }) - - const config = JSON.parse(await read(path.join(dir, "opencode.json"))) - expect(config.skills).toEqual({ urls: ["https://x/skills/"], paths: ["./.openflow/skills"] }) - }) - - test("lists newest first, folder skipped when it has no SKILL.md", async () => { - await call("PUT", "/flow/api/skills/alpha", { body: skill }) - await call("PUT", "/flow/api/skills/beta", { body: { ...skill, name: "Beta" } }) - await fs.mkdir(path.join(paths.skills, "empty"), { recursive: true }) + test("lists a skill folder that is a symlink", async () => { + const elsewhere = path.join(dir, "shared", "linked") + await fs.mkdir(elsewhere, { recursive: true }) + await fs.writeFile(path.join(elsewhere, "SKILL.md"), "---\nname: linked\ndescription: Shared\n---\n\nbody\n") + await fs.mkdir(skillsDir(), { recursive: true }) + await fs.symlink(elsewhere, path.join(skillsDir(), "linked"), "dir") const list = (await call("GET", "/flow/api/skills"))!.body as any[] - expect(list.map((entry) => entry.name).sort()).toEqual(["alpha", "beta"]) + expect(list.map((entry) => entry.name)).toEqual(["linked"]) }) test("lists nothing before anything is saved", async () => { @@ -793,24 +483,100 @@ describe("skills", () => { test("deletes the folder", async () => { await call("PUT", "/flow/api/skills/gone", { body: skill }) - await call("DELETE", "/flow/api/skills/gone") + const result = await call("DELETE", "/flow/api/skills/gone") + expect(result!.body).toEqual({ name: "gone", removed: true }) expect((await call("GET", "/flow/api/skills/gone"))!.status).toBe(404) + expect((await call("DELETE", "/flow/api/skills/gone"))!.body).toEqual({ name: "gone", removed: false }) }) test("keeps a traversing name inside the skills directory", async () => { const result = await call("PUT", `/flow/api/skills/${encodeURIComponent("../../evil")}`, { body: skill }) - expect((result!.body as any).path).toBe(path.join(paths.skills, "evil", "SKILL.md")) - await expect(read(path.join(dir, "..", "evil", "SKILL.md"))).rejects.toThrow() + expect((result!.body as any).path).toBe(path.join(skillsDir(), "evil", "SKILL.md")) + await expect(read(path.join(profile, "..", "evil", "SKILL.md"))).rejects.toThrow() }) - test("does not register the source when the config cannot be parsed", async () => { - await fs.writeFile(path.join(dir, "opencode.json"), "{not json") - const result = await call("PUT", "/flow/api/skills/summarize", { body: skill }) + describe("legacy skills under .openflow/skills", () => { + test("are listed beside the profile's, marked legacy", async () => { + await writeLegacy("old") + await call("PUT", "/flow/api/skills/new", { body: skill }) + + const list = (await call("GET", "/flow/api/skills"))!.body as any[] + const byName = Object.fromEntries(list.map((entry) => [entry.name, entry])) + expect(Object.keys(byName).sort()).toEqual(["new", "old"]) + expect(byName.old).toMatchObject({ description: "Old one", legacy: true }) + expect(byName.new.legacy).toBeUndefined() + }) + + test("can be read for editing, still marked legacy", async () => { + await writeLegacy("old") + expect(((await call("GET", "/flow/api/skills/old"))!.body as any)).toMatchObject({ + name: "old", + folder: "old", + legacy: true, + path: legacyFile("old"), + }) + }) - expect(result!.body).toMatchObject({ registered: false, error: expect.stringContaining("not valid JSON") }) - // the SKILL.md is still written — only the config is left untouched - await expect(read(path.join(paths.skills, "summarize", "SKILL.md"))).resolves.toContain("name: Summarize") - expect(await read(path.join(dir, "opencode.json"))).toBe("{not json") + test("saving one writes the profile and leaves the legacy copy alone", async () => { + await writeLegacy("old") + const before = await read(legacyFile("old")) + + const saved = await call("PUT", "/flow/api/skills/old", { body: { ...skill, name: "old" } }) + + expect((saved!.body as any).path).toBe(path.join(skillsDir(), "old", "SKILL.md")) + expect(await read(legacyFile("old"))).toBe(before) + }) + + test("once re-saved, the profile's copy is the only one listed or read", async () => { + await writeLegacy("old") + await call("PUT", "/flow/api/skills/old", { body: { ...skill, name: "old", description: "Fresh" } }) + + const list = (await call("GET", "/flow/api/skills"))!.body as any[] + expect(list).toHaveLength(1) + expect(list[0]).toMatchObject({ name: "old", description: "Fresh" }) + expect(list[0].legacy).toBeUndefined() + expect(((await call("GET", "/flow/api/skills/old"))!.body as any).legacy).toBeUndefined() + }) + + test("deleting acts on the profile only", async () => { + await writeLegacy("old") + + const result = await call("DELETE", "/flow/api/skills/old") + + expect(result!.body).toEqual({ name: "old", removed: false }) + await expect(read(legacyFile("old"))).resolves.toContain("old body") + }) + }) + + describe("without a known profile directory", () => { + test("writing and deleting are 409, and touch nothing", async () => { + const put = await call("PUT", "/flow/api/skills/summarize", { body: skill, profile: null }) + const del = await call("DELETE", "/flow/api/skills/summarize", { profile: null }) + + for (const response of [put, del]) { + expect(response!.status).toBe(409) + expect(response!.body).toEqual({ error: "omp profile directory unknown: open Omp settings" }) + } + expect(await fs.readdir(dir)).toEqual([]) + }) + + test("reading still shows the legacy skills", async () => { + await writeLegacy("old") + const list = (await call("GET", "/flow/api/skills", { profile: null }))!.body as any[] + expect(list).toMatchObject([{ name: "old", legacy: true }]) + expect((await call("GET", "/flow/api/skills/old", { profile: null }))!.status).toBe(200) + expect((await call("GET", "/flow/api/skills/ghost", { profile: null }))!.status).toBe(404) + }) + }) + + test("a request without a profileDir at all behaves as an unknown profile", async () => { + const response = await handleFlow(paths, { + method: "PUT", + path: "/flow/api/skills/summarize", + search: new URLSearchParams(), + json: async () => skill, + }) + expect(response!.status).toBe(409) }) }) @@ -968,107 +734,231 @@ describe("routes: the pipeline to reopen", () => { }) }) -describe("backupConfig", () => { - test("puts the first copy in .bak and later ones in .prev.bak", async () => { - // .bak is the config as it was before OpenFlow first touched it. Losing it - // is quiet, because a project's opencode.json is usually gitignored. - const target = path.join(dir, "opencode.json") - - expect(await backupConfig(target, "original")).toBe(`${target}.bak`) - expect(await backupConfig(target, "merged once")).toBe(`${target}.prev.bak`) - expect(await backupConfig(target, "merged twice")).toBe(`${target}.prev.bak`) - - expect(await read(`${target}.bak`)).toBe("original") - expect(await read(`${target}.prev.bak`)).toBe("merged twice") - }) -}) - describe("mcp servers", () => { - const configPath = () => path.join(dir, "opencode.json") + const configPath = () => path.join(profile, "mcp.json") + const config = async () => JSON.parse(await read(configPath())) + const local = { type: "local", command: ["bunx", "-y", "@upstash/context7-mcp"] } - test("lists nothing when the project has no config", async () => { + test("lists nothing when the profile has no mcp.json", async () => { expect((await call("GET", "/mcp"))?.body).toEqual([]) }) - test("writes a local server into the project config", async () => { + test("writes a local server as an omp stdio entry, creating the profile directory", async () => { const response = await call("PUT", "/mcp/context7", { - body: { type: "local", command: ["bunx", "-y", "@upstash/context7-mcp"], environment: { KEY: "v" } }, + body: { ...local, environment: { KEY: "v" }, cwd: "/work", timeout: 5000 }, + }) + + expect(response!.status).toBe(200) + expect(response!.body).toEqual({ name: "context7", path: configPath(), backup: undefined }) + expect(await config()).toEqual({ + $schema: expect.stringContaining("mcp-schema.json"), + mcpServers: { + context7: { + type: "stdio", + command: "bunx", + args: ["-y", "@upstash/context7-mcp"], + cwd: "/work", + env: { KEY: "v" }, + timeout: 5000, + enabled: true, + }, + }, }) + }) - expect(response?.status).toBe(200) - const config = JSON.parse(await read(configPath())) - expect(config.mcp.context7).toEqual({ - type: "local", - command: ["bunx", "-y", "@upstash/context7-mcp"], - environment: { KEY: "v" }, + test("writes a remote server as http, with headers", async () => { + await call("PUT", "/mcp/hosted", { + body: { type: "remote", url: " https://mcp.example.com/mcp ", headers: { Authorization: "Bearer x" } }, + }) + + expect((await config()).mcpServers.hosted).toEqual({ + type: "http", + url: "https://mcp.example.com/mcp", + headers: { Authorization: "Bearer x" }, enabled: true, }) }) - test("saving a server the config already describes writes nothing", async () => { - const body = { type: "local", command: ["bunx", "-y", "@upstash/context7-mcp"] } + test("reads entries back in the browser's shape", async () => { + await fs.mkdir(profile, { recursive: true }) + await fs.writeFile( + configPath(), + JSON.stringify({ + mcpServers: { + fs: { command: "npx", args: ["-y", "srv", "/tmp"], env: { A: "1" }, cwd: "/w", timeout: 9 }, + gh: { type: "http", url: "https://gh", headers: { X: "y" }, enabled: false }, + legacy: { type: "sse", url: "https://sse" }, + bare: { url: "https://only-a-url" }, + junk: "not an entry", + }, + }), + ) + + expect((await call("GET", "/mcp"))?.body).toEqual([ + { name: "bare", type: "remote", enabled: true, url: "https://only-a-url", headers: undefined, timeout: undefined }, + { + name: "fs", + type: "local", + enabled: true, + command: ["npx", "-y", "srv", "/tmp"], + cwd: "/w", + environment: { A: "1" }, + timeout: 9, + }, + { name: "gh", type: "remote", enabled: false, url: "https://gh", headers: { X: "y" }, timeout: undefined }, + { name: "legacy", type: "remote", enabled: true, url: "https://sse", headers: undefined, timeout: undefined }, + ]) + }) + + test("round-trips through the list route", async () => { + await call("PUT", "/mcp/beta", { body: { type: "remote", url: "https://b" } }) + await call("PUT", "/mcp/alpha", { + body: { type: "local", command: ["a", "--flag"], environment: { K: "v" }, enabled: false }, + }) - await call("PUT", "/mcp/context7", { body }) + const list = (await call("GET", "/mcp"))!.body as any[] + expect(list.map((entry) => [entry.name, entry.type, entry.enabled])).toEqual([ + ["alpha", "local", false], + ["beta", "remote", true], + ]) + expect(list[0]).toMatchObject({ command: ["a", "--flag"], environment: { K: "v" } }) + expect(list[1]).toMatchObject({ url: "https://b" }) + }) + + test("a name omp would parse differently is rewritten, not stored as typed", async () => { + await call("PUT", `/mcp/${encodeURIComponent("my server")}`, { body: local }) + expect(Object.keys((await config()).mcpServers)).toEqual(["my-server"]) + }) + + test("saving a server the config already describes writes nothing", async () => { + await call("PUT", "/mcp/context7", { body: local }) const before = await read(configPath()) - const second = await call("PUT", "/mcp/context7", { body }) - expect(second?.body).toMatchObject({ unchanged: true }) - expect(second?.body).not.toHaveProperty("backup") + const again = await call("PUT", "/mcp/context7", { body: local }) + + expect(again!.body).toMatchObject({ name: "context7", unchanged: true }) expect(await read(configPath())).toBe(before) - // The project had no config before the first write, so there was nothing to - // copy aside; a re-save must not invent a backup of OpenFlow's own output. await expect(read(`${configPath()}.bak`)).rejects.toThrow() }) - test("writes a remote server with headers", async () => { - await call("PUT", "/mcp/hosted", { - body: { type: "remote", url: "https://mcp.example.com/sse", headers: { Authorization: "Bearer x" } }, + test("keeps every other server, every unknown key and the file's indentation", async () => { + await fs.mkdir(profile, { recursive: true }) + const original = { + $schema: "https://example.test/schema.json", + mcpServers: { + keep: { type: "http", url: "https://k", oauth: { clientId: "c" }, instructions: false }, + mine: { command: "old", auth: { type: "bearer" }, requestIdFormat: "string" }, + }, + disabledServers: ["elsewhere"], + custom: { deep: [1, { two: 2 }] }, + } + const raw = JSON.stringify(original, null, 4) + "\n" + await fs.writeFile(configPath(), raw) + + const response = await call("PUT", "/mcp/mine", { body: { type: "local", command: ["new", "--x"] } }) + + expect(response!.body).toMatchObject({ name: "mine", backup: `${configPath()}.bak` }) + const written = await read(configPath()) + expect(written).toStartWith('{\n "$schema"') + expect(JSON.parse(written)).toEqual({ + ...original, + mcpServers: { + keep: original.mcpServers.keep, + mine: { + auth: { type: "bearer" }, + requestIdFormat: "string", + type: "stdio", + command: "new", + args: ["--x"], + enabled: true, + }, + }, }) + expect(await read(`${configPath()}.bak`)).toBe(raw) + }) - const config = JSON.parse(await read(configPath())) - expect(config.mcp.hosted).toEqual({ - type: "remote", - url: "https://mcp.example.com/sse", - headers: { Authorization: "Bearer x" }, - enabled: true, - }) + test("the backup is the original and a second rewrite does not replace it", async () => { + await fs.mkdir(profile, { recursive: true }) + const raw = JSON.stringify({ mcpServers: { first: { command: "a" } } }) + await fs.writeFile(configPath(), raw) + + await call("PUT", "/mcp/second", { body: { type: "local", command: ["b"] } }) + await call("PUT", "/mcp/third", { body: { type: "local", command: ["c"] } }) + await call("DELETE", "/mcp/first") + + expect(await read(`${configPath()}.bak`)).toBe(raw) + expect(Object.keys((await config()).mcpServers)).toEqual(["second", "third"]) }) - test("refuses a server that could not start, rather than writing a config opencode rejects", async () => { - const local = await call("PUT", "/mcp/broken", { body: { type: "local", command: [] } }) - const remote = await call("PUT", "/mcp/broken", { body: { type: "remote", url: " " } }) + test("a server saved as sse stays sse", async () => { + await fs.mkdir(profile, { recursive: true }) + await fs.writeFile(configPath(), JSON.stringify({ mcpServers: { live: { type: "sse", url: "https://old" } } })) + + await call("PUT", "/mcp/live", { body: { type: "remote", url: "https://new" } }) - expect(local?.status).toBe(400) - expect(remote?.status).toBe(400) - expect(await fs.access(configPath()).then(() => true, () => false)).toBe(false) + expect((await config()).mcpServers.live).toEqual({ type: "sse", url: "https://new", enabled: true }) + }) + + test("switching transport drops the other transport's fields but keeps unknown keys", async () => { + await fs.mkdir(profile, { recursive: true }) + await fs.writeFile( + configPath(), + JSON.stringify({ mcpServers: { s: { command: "run", args: ["a"], env: { K: "v" }, cwd: "/w", oauth: { id: 1 } } } }), + ) + + await call("PUT", "/mcp/s", { body: { type: "remote", url: "https://s" } }) + + expect((await config()).mcpServers.s).toEqual({ oauth: { id: 1 }, type: "http", url: "https://s", enabled: true }) }) test("drops empty header and environment rows the form leaves behind", async () => { await call("PUT", "/mcp/tidy", { - body: { type: "local", command: ["run"], environment: { "": "orphan", KEY: "kept" } }, + body: { type: "local", command: ["run", ""], environment: { "": "orphan", KEY: "kept" } }, }) - - const config = JSON.parse(await read(configPath())) - expect(config.mcp.tidy.environment).toEqual({ KEY: "kept" }) + expect((await config()).mcpServers.tidy).toMatchObject({ command: "run", env: { KEY: "kept" } }) + expect((await config()).mcpServers.tidy.args).toBeUndefined() }) - test("keeps other config and backs it up before rewriting", async () => { - await fs.writeFile(configPath(), JSON.stringify({ agent: { planner: {} } }, null, 2)) + describe("enablement", () => { + test("a server on omp's disabledServers list reads as disabled, and saving it enabled lifts it off", async () => { + await fs.mkdir(profile, { recursive: true }) + await fs.writeFile( + configPath(), + JSON.stringify({ mcpServers: { a: { command: "a" }, b: { command: "b" } }, disabledServers: ["a", "x"] }), + ) + expect(((await call("GET", "/mcp"))!.body as any[]).map((entry) => entry.enabled)).toEqual([false, true]) - await call("PUT", "/mcp/one", { body: { type: "local", command: ["run"] } }) + await call("PUT", "/mcp/a", { body: { type: "local", command: ["a"] } }) + + expect((await config()).disabledServers).toEqual(["x"]) + expect(((await call("GET", "/mcp"))!.body as any[]).map((entry) => entry.enabled)).toEqual([true, true]) + }) + + test("saving a server disabled leaves disabledServers alone", async () => { + await fs.mkdir(profile, { recursive: true }) + await fs.writeFile(configPath(), JSON.stringify({ mcpServers: {}, disabledServers: ["x"] })) + await call("PUT", "/mcp/a", { body: { type: "local", command: ["a"], enabled: false } }) + expect((await config()).disabledServers).toEqual(["x"]) + expect((await config()).mcpServers.a.enabled).toBe(false) + }) - const config = JSON.parse(await read(configPath())) - expect(config.agent).toEqual({ planner: {} }) - expect(JSON.parse(await read(`${configPath()}.bak`)).mcp).toBeUndefined() + test("enabledServers switches an enabled:false entry back on", async () => { + await fs.mkdir(profile, { recursive: true }) + await fs.writeFile( + configPath(), + JSON.stringify({ mcpServers: { a: { command: "a", enabled: false } }, enabledServers: ["a"] }), + ) + expect(((await call("GET", "/mcp"))!.body as any[])[0].enabled).toBe(true) + }) }) - test("round-trips through the list route", async () => { - await call("PUT", "/mcp/beta", { body: { type: "remote", url: "https://b" } }) - await call("PUT", "/mcp/alpha", { body: { type: "local", command: ["a"], enabled: false } }) + test("refuses a server that could not start, rather than writing a config omp rejects", async () => { + const local = await call("PUT", "/mcp/broken", { body: { type: "local", command: [] } }) + const remote = await call("PUT", "/mcp/broken", { body: { type: "remote", url: " " } }) - const rows = (await call("GET", "/mcp"))?.body as any[] - expect(rows.map((row) => row.name)).toEqual(["alpha", "beta"]) - expect(rows[0]).toMatchObject({ type: "local", enabled: false, command: ["a"] }) + expect(local!.status).toBe(400) + expect(remote!.status).toBe(400) + await expect(read(configPath())).rejects.toThrow() }) test("delete removes only the named server", async () => { @@ -1077,75 +967,83 @@ describe("mcp servers", () => { const response = await call("DELETE", "/mcp/one") - expect(response?.body).toMatchObject({ name: "one", removed: true }) - expect(Object.keys(JSON.parse(await read(configPath())).mcp)).toEqual(["two"]) + expect(response!.body).toMatchObject({ name: "one", removed: true, backup: `${configPath()}.bak` }) + expect(Object.keys((await config()).mcpServers)).toEqual(["two"]) }) - test("deleting something that was never configured is not an error", async () => { + test("deleting something that was never configured is not an error, and creates nothing", async () => { expect((await call("DELETE", "/mcp/ghost"))?.body).toMatchObject({ removed: false }) - }) + await expect(fs.access(profile)).rejects.toThrow() - test("refuses to touch an opencode.json it cannot parse", async () => { - await fs.writeFile(configPath(), "{ not json") + await call("PUT", "/mcp/real", { body: { type: "local", command: ["a"] } }) + const before = await read(configPath()) + expect((await call("DELETE", "/mcp/ghost"))?.body).toMatchObject({ removed: false }) + expect(await read(configPath())).toBe(before) + }) - const response = await call("PUT", "/mcp/one", { body: { type: "local", command: ["a"] } }) + test("an empty mcp.json is a profile with no servers", async () => { + await fs.mkdir(profile, { recursive: true }) + await fs.writeFile(configPath(), "") - expect(response?.status).toBe(400) - expect(await read(configPath())).toBe("{ not json") + expect((await call("GET", "/mcp"))?.body).toEqual([]) + const saved = await call("PUT", "/mcp/one", { body: { type: "local", command: ["a"] } }) + expect(saved!.status).toBe(200) + expect(Object.keys((await config()).mcpServers)).toEqual(["one"]) }) -}) -describe("server control", () => { - test("reports the engine this host proxies", async () => { - const response = await call("GET", "/flow/api/server", { serve: supervisor({ managed: true, pid: 42 }) }) - expect(response!.status).toBe(200) - expect(response!.body).toMatchObject({ managed: true, running: true, pid: 42 }) - }) + test("refuses to touch an mcp.json it cannot read as an object, and says why", async () => { + for (const source of ["{ not json", "[]", '{"mcpServers": []}']) { + await fs.mkdir(profile, { recursive: true }) + await fs.writeFile(configPath(), source) - test("restarts it when this host owns the process", async () => { - let restarted = 0 - const serve = supervisor({ managed: true }, async () => { - restarted += 1 - return { managed: true, running: true, url: "http://127.0.0.1:4096", command: "x", pid: 7 } - }) - const response = await call("POST", "/flow/api/server/restart", { serve }) - expect(restarted).toBe(1) - expect(response!.status).toBe(200) - expect(response!.body).toMatchObject({ running: true, pid: 7 }) - }) + const put = await call("PUT", "/mcp/one", { body: { type: "local", command: ["a"] } }) + const del = await call("DELETE", "/mcp/one") - test("refuses with the command when the host does not own it", async () => { - const serve = supervisor({ managed: false, reason: "it was started outside OpenFlow" }) - const response = await call("POST", "/flow/api/server/restart", { serve }) - // 409, not 500: nothing is broken — this host simply cannot reach that - // process, and the body carries what to type instead. - expect(response!.status).toBe(409) - expect(response!.body).toMatchObject({ managed: false, command: "opencode serve --port 4096" }) - expect((response!.body as any).error).toContain("outside OpenFlow") + expect(put!.status).toBe(400) + expect((put!.body as any).error).toContain("mcp.json") + expect(del!.status).toBe(400) + expect(await read(configPath())).toBe(source) + expect((await call("GET", "/mcp"))?.body).toEqual([]) + } }) - test("surfaces a failed restart rather than claiming success", async () => { - const serve = supervisor({ managed: true }, async () => { - throw new Error("port still held") + test("two saves at once both survive", async () => { + await Promise.all([ + call("PUT", "/mcp/alpha", { body: { type: "local", command: ["a"] } }), + call("PUT", "/mcp/beta", { body: { type: "remote", url: "https://b" } }), + ]) + expect(Object.keys((await config()).mcpServers).sort()).toEqual(["alpha", "beta"]) + }) + + test("an unnamed write is a 400, and other verbs fall through", async () => { + expect((await call("PUT", "/mcp", { body: local }))!.status).toBe(400) + expect(await call("PATCH", "/mcp/one")).toBeUndefined() + expect(await call("GET", "/mcp/one")).toBeUndefined() + }) + + describe("without a known profile directory", () => { + test("every route is 409 and nothing is written", async () => { + const responses = [ + await call("GET", "/mcp", { profile: null }), + await call("PUT", "/mcp/one", { body: local, profile: null }), + await call("DELETE", "/mcp/one", { profile: null }), + ] + for (const response of responses) { + expect(response!.status).toBe(409) + expect(response!.body).toEqual({ error: "omp profile directory unknown: open Omp settings" }) + } + expect(await fs.readdir(dir)).toEqual([]) }) - const response = await call("POST", "/flow/api/server/restart", { serve }) - expect(response!.status).toBe(502) - expect((response!.body as any).error).toBe("port still held") - }) - test("a host that tracks no engine says so", async () => { - const response = await call("GET", "/flow/api/server") - expect(response!.status).toBe(501) - }) - - test("is refused when the store is served remotely", async () => { - process.env.FLOW_ALLOW_REMOTE = "1" - try { - const response = await call("POST", "/flow/api/server/restart", { serve: supervisor({ managed: true }) }) - expect(response!.status).toBe(403) - } finally { - delete process.env.FLOW_ALLOW_REMOTE - } + test("a request that carries no profileDir is the same", async () => { + const response = await handleFlow(paths, { + method: "GET", + path: "/mcp", + search: new URLSearchParams(), + json: async () => ({}), + }) + expect(response!.status).toBe(409) + }) }) }) @@ -1195,81 +1093,3 @@ describe("custom roles", () => { expect([["first"], ["first", "second"]]).toContainEqual(ids) }) }) - -/** - * The one route that writes *outside* the project: repackaging a provider edits - * opencode's global config, because that is the only file a run reads (see - * `lib/repackage.ts`). `XDG_CONFIG_HOME` points at the temp directory here, so - * the suite cannot touch the developer's own config. - */ -describe("routes: repackaging a provider", () => { - let target: string - - beforeEach(() => { - process.env.XDG_CONFIG_HOME = path.join(dir, "xdg") - target = path.join(dir, "xdg", "opencode", "opencode.json") - }) - - afterEach(() => { - delete process.env.XDG_CONFIG_HOME - }) - - test("reports what is available before any config exists", async () => { - const response = await call("GET", "/flow/api/repackage") - expect(response!.status).toBe(200) - expect(response!.body).toMatchObject({ path: target, applied: [] }) - expect((response!.body as any).available).toContain("openrouter") - }) - - test("writes the override and asks for a restart", async () => { - const response = await call("POST", "/flow/api/repackage", { body: { providers: ["openrouter"] } }) - expect(response!.body).toMatchObject({ changed: ["openrouter"], applied: ["openrouter"], restart: true }) - expect(JSON.parse(await read(target)).providers.openrouter.api).toEqual({ - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://openrouter.ai/api/v1", - }) - expect((await call("GET", "/flow/api/repackage"))!.body).toMatchObject({ applied: ["openrouter"] }) - }) - - test("follows the dialect of an existing v1 config, and backs it up", async () => { - await fs.mkdir(path.dirname(target), { recursive: true }) - await fs.writeFile(target, JSON.stringify({ plugin: ["./p.js"] })) - const response = await call("POST", "/flow/api/repackage", { body: { providers: ["groq"] } }) - expect(response!.body).toMatchObject({ backup: `${target}.bak` }) - expect(JSON.parse(await read(target)).provider.groq).toEqual({ - npm: "@ai-sdk/openai-compatible", - api: "https://api.groq.com/openai/v1", - }) - expect(JSON.parse(await read(`${target}.bak`))).toEqual({ plugin: ["./p.js"] }) - }) - - test("a second call changes nothing and does not ask for a restart", async () => { - await call("POST", "/flow/api/repackage", { body: { providers: ["groq"] } }) - const again = await call("POST", "/flow/api/repackage", { body: { providers: ["groq"] } }) - expect(again!.body).toMatchObject({ changed: [], restart: false }) - }) - - test("refuses a provider the runner has no profile for, so no npm package can be injected", async () => { - const response = await call("POST", "/flow/api/repackage", { body: { providers: ["evil"] } }) - expect(response!.status).toBe(400) - await expect(read(target)).rejects.toThrow() - }) - - test("refuses rather than stripping comments out of a hand-written config", async () => { - await fs.mkdir(path.dirname(target), { recursive: true }) - await fs.writeFile(target, '{\n // mine\n "model": "opencode/x"\n}') - const response = await call("POST", "/flow/api/repackage", { body: { providers: ["groq"] } }) - expect(response!.status).toBe(409) - expect((response!.body as any).error).toContain("comments") - expect(await read(target)).toContain("// mine") - }) - - test("reads .jsonc when that is the file opencode would load", async () => { - await fs.mkdir(path.dirname(target), { recursive: true }) - await fs.writeFile(`${target}c`, JSON.stringify({ model: "opencode/x" })) - const response = await call("POST", "/flow/api/repackage", { body: { providers: ["groq"] } }) - expect(response!.body).toMatchObject({ path: `${target}c`, changed: ["groq"] }) - await expect(read(target)).rejects.toThrow() - }) -}) diff --git a/packages/flow/lib/store.ts b/packages/flow/lib/store.ts index a7ab05f880..e550013c64 100644 --- a/packages/flow/lib/store.ts +++ b/packages/flow/lib/store.ts @@ -1,17 +1,13 @@ import fs from "node:fs/promises" import path from "node:path" -import { cliAuthPath, importCliKeys, readCliKeys } from "./cli-auth" +import { isDeepStrictEqual } from "node:util" import { allowsRemote } from "./guard" import { recallPipeline, rememberPipeline, rememberProject } from "./last-session" import { hasNativePicker, pickFolderNative } from "./native-picker" -import type { Supervisor } from "./opencode-process" -import { COMPATIBLE_PROFILES, globalConfigCandidates, repackage, repackaged } from "./repackage" -import { install, installed, uninstall } from "./dispatch-tool" import { checkpoint, dropCheckpoints } from "./checkpoint" import { dropScratch, openScratch } from "./scratch" import { treeStat } from "./diffstat" import { cleanupWorktrees, mergeWorktrees, openWorktrees } from "./worktree" -import { zenModels } from "./zen" /** * OpenFlow's persistence, as plain functions over a project directory. @@ -26,15 +22,13 @@ import { zenModels } from "./zen" * GET /flow/api/pipelines/:name -> pipeline json * PUT /flow/api/pipelines/:name -> save pipeline json * DELETE /flow/api/pipelines/:name -> delete pipeline - * POST /flow/api/pipelines/:name/agents?merge=1 - * -> write generated opencode agent defs * GET /flow/api/mcp -> [{ name, type, enabled, command?, url?, ... }] - * PUT /flow/api/mcp/:name -> upsert an mcp server in opencode.json - * DELETE /flow/api/mcp/:name -> drop it from opencode.json - * GET /flow/api/skills -> [{ name, description, updated }] - * GET /flow/api/skills/:name -> { name, folder, description, content, path } - * PUT /flow/api/skills/:name -> write .openflow/skills//SKILL.md - * DELETE /flow/api/skills/:name -> delete the skill folder + * PUT /flow/api/mcp/:name -> upsert an mcp server in the Omp profile's mcp.json + * DELETE /flow/api/mcp/:name -> drop it from there + * GET /flow/api/skills -> [{ name, description, updated, legacy? }] + * GET /flow/api/skills/:name -> { name, folder, description, content, path, legacy? } + * PUT /flow/api/skills/:name -> write /skills//SKILL.md + * DELETE /flow/api/skills/:name -> delete that skill folder * GET /flow/api/runs -> [{ id, pipeline, status, started, finished }] * GET /flow/api/runs/:id -> run log json * PUT /flow/api/runs/:id -> write run log json @@ -44,28 +38,19 @@ import { zenModels } from "./zen" * POST /flow/api/worktrees/cleanup -> { run, trees } -> remove the trees and branches * DELETE /flow/api/runs/:id -> delete one run log * POST /flow/api/runs/prune -> { keep?, days? } -> { removed: [id], kept } - * GET /flow/api/cli-keys -> { path, providers } from the CLI's auth.json - * POST /flow/api/cli-keys/import -> connect those keys to `opencode serve` * GET /flow/api/env?names=A,B -> { present: ["A"] } — names only, never values - * GET /flow/api/zen-models -> { ids: [...] | null } — what zen really serves * GET /flow/api/browse?path= -> { path, parent, entries } — subdirectories of `path` * (drive roots on Windows when `path` is omitted) * POST /flow/api/pick-folder -> { path } — native OS folder dialog on the host, * `null` when cancelled (loopback hosts only) * POST /flow/api/project -> { path } — switch the live project directory, * and remember it for the next launch - * GET /flow/api/dispatch-tool -> { path, present, current, root } — whether the global - * config starts OpenFlow's dispatch MCP server - * POST /flow/api/dispatch-tool -> installs (or repoints) it there; DELETE removes it. - * Both need an engine restart to take effect - * GET /flow/api/repackage -> { path, applied, available, error? } — which - * OpenAI-compatible providers the global config - * already repackages - * POST /flow/api/repackage -> { providers: [...] } repackages them there, so the - * runner can dispatch their models - * GET /flow/api/server -> { managed, running, url, command, pid, ... } — the - * `opencode serve` this host proxies - * POST /flow/api/server/restart -> restarts it, when this host owns it (loopback only) + * + * MCP servers and skills belong to the Omp profile the cards run under, not to + * the project: the composer hands over that profile's config directory as + * `profileDir`, and both routes answer 409 until it is known. Skills authored + * by older builds under `.openflow/skills` are still listed (`legacy: true`) + * but never written, moved or deleted here. * * Keeping it host-neutral is the point: the dev server and the built app must * not drift into two different stores. `browse`/`project` read the same host @@ -161,13 +146,12 @@ export type FlowRequest = { path: string search: URLSearchParams json: () => Promise - /** Base URL of the `opencode serve` this host proxies, for the key import. */ - upstream?: string /** - * The engine's process, when this host spawned it. Absent or unmanaged means - * the restart route reports the command instead of running it. + * The config directory of the Omp profile the cards run under (what + * `omp --profile config path` prints). Unknown until Omp settings + * have been resolved, which the mcp and skills routes report as a 409. */ - serve?: Supervisor + profileDir?: () => Promise } export type FlowResponse = { status: number; body: unknown } @@ -203,10 +187,6 @@ export async function handleFlow(paths: FlowPaths, request: FlowRequest): Promis const file = path.join(paths.pipelines, `${name}.json`) - if (segments[2] === "agents" && method === "POST") { - return ok(await writeAgents(paths, name, await request.json(), request.search.get("merge") === "1")) - } - // Opening and saving are the two acts that mean "this is what I am working // on", so they are where the pipeline to reopen is recorded. Noting it on a // GET is a side effect on a read, which earns its keep here: it keeps the @@ -248,13 +228,16 @@ export async function handleFlow(paths: FlowPaths, request: FlowRequest): Promis if (segments[0] === "mcp") { const name = segments[1] ? mcpName(decodeURIComponent(segments[1])) : undefined - if (!name && method === "GET") return ok(await listMcpServers(paths)) - if (!name) return { status: 400, body: { error: "mcp server name required" } } + if (!name && method !== "GET") return { status: 400, body: { error: "mcp server name required" } } - if (method === "PUT" || method === "DELETE") { + if (!name || method === "PUT" || method === "DELETE") { + const profile = await request.profileDir?.() + if (!profile) return noProfile() + const file = path.join(profile, "mcp.json") + if (!name) return ok(await listMcpServers(file)) try { - if (method === "DELETE") return ok(await deleteMcpServer(paths, name)) - return ok(await writeMcpServer(paths, name, await request.json())) + if (method === "DELETE") return ok(await deleteMcpServer(file, name)) + return ok(await writeMcpServer(file, name, await request.json())) } catch (error) { return { status: 400, body: { error: error instanceof Error ? error.message : String(error) } } } @@ -264,44 +247,35 @@ export async function handleFlow(paths: FlowPaths, request: FlowRequest): Promis if (segments[0] === "skills") { const name = segments[1] ? slug(decodeURIComponent(segments[1])) : undefined - if (!name && method === "GET") return ok(await listSkills(paths)) - if (!name) return { status: 400, body: { error: "skill name required" } } + if (!name && method !== "GET") return { status: 400, body: { error: "skill name required" } } + + // Reading works without a profile — the legacy skills live in the project — + // but anything that writes has nowhere to go. + if (!name) return ok(await listSkills(paths, await request.profileDir?.())) if (method === "GET") { - const found = await readSkill(paths, name) + const found = await readSkill(paths, await request.profileDir?.(), name) if (!found) return { status: 404, body: { error: `skill "${name}" not found` } } return ok(found) } - if (method === "PUT") return ok(await writeSkill(paths, name, await request.json())) - if (method === "DELETE") { - await fs.rm(path.join(paths.skills, name), { recursive: true, force: true }) - return ok({ name }) - } - } - - if (segments[0] === "cli-keys") { - // Only the provider names cross to the browser. Importing runs here, so - // the secrets in auth.json are never served over HTTP. - if (!segments[1] && method === "GET") { - const keys = await readCliKeys() - return ok({ path: cliAuthPath(), providers: keys.map((entry) => entry.providerID) }) - } - if (segments[1] === "import" && method === "POST") { - if (!request.upstream) return { status: 500, body: { error: "no opencode server configured" } } - const body = await request.json() - const only = Array.isArray(body?.providers) ? body.providers.map(String) : undefined - const keys = await readCliKeys() - if (!keys.length) return { status: 404, body: { error: `no API keys in ${cliAuthPath()}` } } - return ok({ results: await importCliKeys({ upstream: request.upstream, keys, only }) }) + if (method === "PUT" || method === "DELETE") { + const profile = await request.profileDir?.() + if (!profile) return noProfile() + if (method === "PUT") return ok(await writeSkill(profile, name, await request.json())) + const dir = path.join(profile, "skills", name) + const existed = await fs.stat(dir).then( + () => true, + () => false, + ) + await fs.rm(dir, { recursive: true, force: true }) + return ok({ name, removed: existed }) } } if (segments[0] === "env" && method === "GET") { // Which of the asked-for names are set, and nothing else: the answer is a // subset of the question, so no value and no unrelated variable can leak - // to the browser. `opencode serve` reads the same environment when it is - // started from the same shell — a server launched elsewhere may not, and - // then a provider reads as locked until its key is stored here instead. + // to the browser. const asked = (request.search.get("names") ?? "") .split(",") .map((name) => name.trim()) @@ -318,59 +292,6 @@ export async function handleFlow(paths: FlowPaths, request: FlowRequest): Promis } } - if (segments[0] === "dispatch-tool") { - if (method === "GET") return ok(await dispatchToolStatus()) - if (method === "POST" || method === "DELETE") { - const result = await applyDispatchTool(method === "POST") - if ("error" in result) return { status: 409, body: result } - return ok(result) - } - } - - if (segments[0] === "repackage") { - if (method === "GET") return ok(await repackageStatus()) - if (method === "POST") { - const body = await request.json().catch(() => ({})) - const asked = Array.isArray(body?.providers) ? body.providers.map(String) : [] - // Only ids in the profile table are accepted, so no request can name an - // arbitrary npm package for the engine to load. - const ids = asked.filter((id: string) => COMPATIBLE_PROFILES[id]) - if (!ids.length) return { status: 400, body: { error: "no repackageable provider named" } } - const result = await applyRepackage(ids) - if ("error" in result) return { status: 409, body: result } - return ok(result) - } - } - - if (segments[0] === "server") { - if (!request.serve) return { status: 501, body: { error: "this host does not track `opencode serve`" } } - if (segments.length === 1 && method === "GET") return ok(await request.serve.status()) - if (segments[1] === "restart" && method === "POST") { - // Restarting spawns a process on the host. Served remotely that is a - // remote process-control hole, so it is refused for the same reason the - // native folder picker is — see `lib/guard.ts`. - if (allowsRemote()) - return { status: 403, body: { error: "restarting the engine is unavailable when serving remotely" } } - const before = await request.serve.status() - if (!before.managed) { - // The C fallback: say who owns it and what to type, rather than - // pretending a click can reach a process this host never spawned. - return { - status: 409, - body: { error: before.reason ?? "this host does not own `opencode serve`", ...before }, - } - } - try { - return ok(await request.serve.restart()) - } catch (error) { - return { - status: 502, - body: { error: error instanceof Error ? error.message : String(error), ...(await request.serve.status()) }, - } - } - } - } - if (segments[0] === "pick-folder" && method === "POST") { // The dialog opens on the *host*, so it only makes sense when the host is // the machine the user is sitting at. Served remotely it would pop a window @@ -402,14 +323,6 @@ export async function handleFlow(paths: FlowPaths, request: FlowRequest): Promis return ok(paths) } - if (segments[0] === "zen-models" && method === "GET") { - // Fetched here rather than in the browser: opencode.ai serves no CORS - // headers for this, and the host is already the side that talks to the - // network on OpenFlow's behalf. `null` means "could not read" — the caller - // must then leave the catalog alone rather than empty it. - return ok({ ids: (await zenModels()) ?? null }) - } - // A snapshot of what the project's tree currently has that HEAD does not. // Meaningless on its own: the engine takes one before a batch and one after, // and the difference is what that batch wrote. @@ -688,117 +601,6 @@ async function updateRunsIndex(paths: FlowPaths, id: string, body: any) { }) } -/** - * Writes the generated `agent` block. By default it lands in - * `.openflow/generated/.opencode.json` so nothing is touched behind the - * user's back; `merge` folds the block into the config that a run actually - * reads after copying it aside. - * - * That config is the **global** one, not the project's. A session's location is - * the engine's cwd, never `OPENFLOW_PROJECT`, so agents merged into the target - * project are not there at drain time: `AgentV2.select` returns - * `{ id, info: undefined }` for the unknown name and the card runs with no - * system prompt and the default tool set — or, once preflight checks the - * server's agent list, the run refuses outright with "the server does not know - * an agent named …". Measured on the first real gauntlet run, where all six - * cards failed that way one second after Run. - */ -async function writeAgents(paths: FlowPaths, name: string, body: any, merge: boolean) { - const block = { $schema: "https://opencode.ai/config.json", agent: body?.agent ?? {} } - await fs.mkdir(paths.generated, { recursive: true }) - const file = path.join(paths.generated, `${name}.opencode.json`) - await writeAtomic(file, JSON.stringify(block, null, 2) + "\n") - if (!merge) return { path: file, merged: false } - - const global = await readGlobalConfig() - const target = global.target - const pipeline = rawPipelineName(body, name) - return serialize(target, async () => { - const config = await readGlobalConfig() - if ("error" in config && config.error) return { path: file, merged: false, error: config.error } - // Fold the generated agents onto whatever is already there, dropping the ones - // this pipeline generated last time that it no longer generates — a renamed - // or deleted node would otherwise leave its agent behind forever, and a run - // merges on every start, so the leftovers accumulate. Only entries this - // pipeline is known to have written are dropped: they are recognised by the - // description writeAgents itself stamps on them, so a hand-written agent is - // never touched, whatever it is called. - const current: Record = config.value.agent ?? {} - const kept = Object.fromEntries( - Object.entries(current).filter(([key, value]) => key in block.agent || !generatedFor(value, pipeline)), - ) - const nextAgent = { ...kept, ...block.agent } - - // Bail before touching disk when that leaves the block identical — an - // auto-merge on every run must not churn opencode.json or litter a `.bak` - // when nothing actually changed. Key order is ignored so a re-serialised but - // equivalent config still counts as unchanged. When there is no config at all - // and nothing to write into one, the generated file is the only path there is - // to report. - if (stableEqual(current, nextAgent)) - return { path: config.raw === undefined ? file : target, merged: false, unchanged: true } - if (config.comments) return { path: file, merged: false, error: CONFIG_HAS_COMMENTS } - const backup = config.raw === undefined ? undefined : await backupConfig(target, config.raw) - // A machine that has never configured opencode has no `~/.config/opencode` - // to write into. - await fs.mkdir(path.dirname(target), { recursive: true }) - config.value.agent = nextAgent - await writeAtomic(target, JSON.stringify(config.value, null, 2) + "\n") - return { path: target, merged: true, backup } - }) -} - -/** - * The pipeline's name as the user typed it. - * - * `agentBlock` stamps that raw name into every generated description, so it is - * what `generatedFor` has to match on — but the route only ever carries the - * slug (`my-flow` for `My Flow`), and matching on the slug quietly recognises - * nothing. The agents of renamed or deleted nodes then survive forever and - * every rename adds another generation. A caller that sends `pipeline` in the - * body answers it directly; otherwise it is read back off the descriptions in - * the block itself, which carry it verbatim. - */ -function rawPipelineName(body: any, slugged: string) { - if (typeof body?.pipeline === "string" && body.pipeline.trim()) return body.pipeline.trim() - for (const entry of Object.values(body?.agent ?? {})) { - const description = entry?.description - const match = typeof description === "string" ? / of pipeline (.+)$/.exec(description) : undefined - if (match) return match[1] - } - return slugged -} - -/** - * Whether an existing agent entry is one OpenFlow generated for this pipeline. - * - * The marker is the description `agentBlock` writes — `OpenFlow node - * () of pipeline ` — rather than the agent's key, because keys are - * derived from the pipeline and role and a hand-written agent is free to - * collide with that shape. An entry that does not carry the marker is somebody - * else's and is left alone. - */ -function generatedFor(entry: unknown, pipeline: string): boolean { - if (!entry || typeof entry !== "object") return false - const description = (entry as { description?: unknown }).description - return typeof description === "string" && description.endsWith(` of pipeline ${pipeline}`) -} - -/** Canonical JSON with object keys sorted, so two equal configs compare equal regardless of key order. */ -function stableStringify(value: unknown): string { - if (value === null || typeof value !== "object") return JSON.stringify(value) - if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]` - const record = value as Record - return `{${Object.keys(record) - .sort() - .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) - .join(",")}}` -} - -function stableEqual(a: unknown, b: unknown): boolean { - return stableStringify(a) === stableStringify(b) -} - export type McpServer = { name: string type: "local" | "remote" @@ -813,55 +615,76 @@ export type McpServer = { timeout?: number } +function noProfile(): FlowResponse { + return { status: 409, body: { error: "omp profile directory unknown: open Omp settings" } } +} + /** - * MCP server names become part of a tool name (`_`), so anything - * the sanitiser would rewrite is rejected here instead of silently producing a - * server nobody can address from a per-node allowlist. + * MCP server names become part of a tool name (`mcp___`), so + * anything the sanitiser would rewrite is rejected here instead of silently + * producing a server nobody can address from a per-node allowlist. This is + * stricter than omp itself, which also accepts `.`, `:` and inner spaces. */ export function mcpName(value: string) { return value.trim().replace(/[^a-zA-Z0-9-_]/g, "-") } +const MCP_SCHEMA = + "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json" + +/** + * The keys of a server entry that the browser's `McpServer` form owns. Every + * other key of an existing entry (`auth`, `oauth`, `instructions`, + * `requestIdFormat`, ...) belongs to the user and survives an edit untouched. + */ +const MCP_MANAGED = ["type", "command", "args", "env", "cwd", "url", "headers", "timeout", "enabled"] + /** - * The `mcp` block of the project's opencode.json, as rows. + * The `mcpServers` block of the Omp profile's `mcp.json`, as rows. * - * This is the *configured* set, not the live one — connection state comes from - * the server's own `GET /mcp`, and a server can be configured here while the - * running `opencode serve` has never heard of it (it reads config once at - * boot). The panel shows both, side by side, for exactly that reason. + * An unreadable file lists as empty rather than throwing: the panel must still + * open so the user can see there is nothing to show. Writes refuse the same + * file instead, so it is never overwritten. */ -async function listMcpServers(paths: FlowPaths): Promise { - const config = await readProjectConfig(paths) - if ("error" in config) return [] - const block = config.value.mcp - if (!block || typeof block !== "object") return [] - return Object.entries(block as Record) - .map(([name, entry]) => normalizeMcp(name, entry)) +async function listMcpServers(file: string): Promise { + const config = await readMcpFile(file).catch(() => undefined) + if (!config) return [] + const servers = (config.value.mcpServers ?? {}) as Record + const disabled = stringList(config.value.disabledServers) + const forced = stringList(config.value.enabledServers) + return Object.entries(servers) + .map(([name, entry]) => normalizeMcp(name, entry, !disabled.includes(name), forced.includes(name))) .filter((entry): entry is McpServer => Boolean(entry)) .sort((a, b) => a.name.localeCompare(b.name)) } -function normalizeMcp(name: string, entry: any): McpServer | undefined { +/** + * `enabled` follows omp: `disabledServers` always wins, an `enabled: false` + * entry is switched back on only by `enabledServers`. + */ +function normalizeMcp(name: string, entry: any, listed: boolean, forced: boolean): McpServer | undefined { if (!entry || typeof entry !== "object") return undefined - const enabled = entry.enabled !== false - if (entry.type === "remote") { + const enabled = listed && (entry.enabled !== false || forced) + const timeout = typeof entry.timeout === "number" ? entry.timeout : undefined + // A URL-only entry is http, which is how omp's own discovery reads it. + if (entry.type === "http" || entry.type === "sse" || (entry.type === undefined && !entry.command && entry.url)) { return { name, type: "remote", enabled, url: typeof entry.url === "string" ? entry.url : "", headers: isStringRecord(entry.headers) ? entry.headers : undefined, - timeout: typeof entry.timeout === "number" ? entry.timeout : undefined, + timeout, } } return { name, type: "local", enabled, - command: Array.isArray(entry.command) ? entry.command.map(String) : [], + command: typeof entry.command === "string" ? [entry.command, ...stringList(entry.args)] : [], cwd: typeof entry.cwd === "string" ? entry.cwd : undefined, - environment: isStringRecord(entry.environment) ? entry.environment : undefined, - timeout: typeof entry.timeout === "number" ? entry.timeout : undefined, + environment: isStringRecord(entry.env) ? entry.env : undefined, + timeout, } } @@ -871,43 +694,75 @@ function isStringRecord(value: unknown): value is Record { ) } +function stringList(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [] +} + /** - * Writes one server into the project config's `mcp` block, after a backup. + * Reads `mcp.json`. A missing file is a profile that has no MCP servers yet, + * not an error — but anything else that stops it being read as an object is, + * because writing over it would destroy hand-written config. + */ +async function readMcpFile(file: string): Promise<{ value: Record; raw: string | undefined }> { + const raw = await fs.readFile(file, "utf8").catch((error) => { + if (error?.code === "ENOENT") return undefined + throw error + }) + if (raw === undefined || !raw.trim()) return { value: {}, raw } + const value = jsonOrUndefined(raw) + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error("existing mcp.json is not a valid JSON object") + const servers = value.mcpServers + if (servers !== undefined && (!servers || typeof servers !== "object" || Array.isArray(servers))) + throw new Error("existing mcp.json has an mcpServers that is not an object") + return { value, raw } +} + +/** + * Writes one server into `mcpServers`, after a backup. * - * Validation is not politeness: opencode hard-fails on a config it cannot - * parse, and a config it refuses takes every card in the project down with it, - * not just this server. A local server with no command and a remote one with - * no url are the two ways to produce that, so both are refused here. + * Validation is not politeness: a local server with no command and a remote + * one with no url are entries omp rejects at load, and a config it refuses + * takes the server list of every card down, not just this server. + * + * Saving an enabled server also lifts it off `disabledServers`, which would + * otherwise keep it off whatever its own `enabled` says. */ -async function writeMcpServer(paths: FlowPaths, name: string, body: any) { - const entry = buildMcpEntry(body) - const target = path.join(paths.project, "opencode.json") - return serialize(target, async () => { - const config = await readProjectConfig(paths) - if ("error" in config) throw new Error(config.error) - // Saving a server the config already describes writes nothing, for the same - // reason an unchanged agent merge does: a pointless rewrite would push the - // pre-OpenFlow `.bak` one slot further out of reach. - if (stableEqual(config.value.mcp?.[name], entry)) return { name, path: target, unchanged: true } - if (config.comments) throw new Error(CONFIG_HAS_COMMENTS) - const backup = config.raw === undefined ? undefined : await backupConfig(target, config.raw) - const next = config.value - next.mcp = { ...(next.mcp ?? {}), [name]: entry } - if (!next.$schema) next.$schema = "https://opencode.ai/config.json" - await writeAtomic(target, JSON.stringify(next, null, 2) + "\n") - return { name, path: target, backup } +async function writeMcpServer(file: string, name: string, body: any) { + return serialize(file, async () => { + const config = await readMcpFile(file) + const servers = (config.value.mcpServers ?? {}) as Record + const entry = buildMcpEntry(body, servers[name]) + const disabled = stringList(config.value.disabledServers) + const lift = entry.enabled && disabled.includes(name) + // Saving a server the config already describes writes nothing, so a + // pointless rewrite does not push the pre-OpenFlow `.bak` out of reach. + if (isDeepStrictEqual(servers[name], entry) && !lift) return { name, path: file, unchanged: true } + const next: Record = { + ...(config.raw?.trim() ? {} : { $schema: MCP_SCHEMA }), + ...config.value, + mcpServers: { ...servers, [name]: entry }, + } + if (lift) next.disabledServers = disabled.filter((item) => item !== name) + const backup = await backupOnce(file, config.raw) + await fs.mkdir(path.dirname(file), { recursive: true }) + await writeAtomic(file, jsonText(next, config.raw)) + return { name, path: file, backup } }) } -function buildMcpEntry(body: any) { +function buildMcpEntry(body: any, existing: Record | undefined) { const enabled = body?.enabled !== false const timeout = typeof body?.timeout === "number" && body.timeout > 0 ? body.timeout : undefined + const kept = Object.fromEntries(Object.entries(existing ?? {}).filter(([key]) => !MCP_MANAGED.includes(key))) if (body?.type === "remote") { const url = typeof body.url === "string" ? body.url.trim() : "" if (!url) throw new Error("a remote mcp server needs a url") const headers = pickStrings(body.headers) return { - type: "remote" as const, + ...kept, + // The form has no transport switch, so a server saved as sse stays sse. + type: existing?.type === "sse" ? "sse" : "http", url, ...(headers ? { headers } : {}), ...(timeout ? { timeout } : {}), @@ -916,13 +771,15 @@ function buildMcpEntry(body: any) { } const command = Array.isArray(body?.command) ? body.command.map(String).filter((part: string) => part.length) : [] if (!command.length) throw new Error("a local mcp server needs a command") - const environment = pickStrings(body?.environment) + const env = pickStrings(body?.environment) const cwd = typeof body?.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : undefined return { - type: "local" as const, - command, + ...kept, + type: "stdio", + command: command[0], + ...(command.length > 1 ? { args: command.slice(1) } : {}), ...(cwd ? { cwd } : {}), - ...(environment ? { environment } : {}), + ...(env ? { env } : {}), ...(timeout ? { timeout } : {}), enabled, } @@ -939,242 +796,22 @@ function pickStrings(value: unknown) { return Object.keys(out).length ? out : undefined } -async function deleteMcpServer(paths: FlowPaths, name: string) { - const target = path.join(paths.project, "opencode.json") - return serialize(target, async () => { - const config = await readProjectConfig(paths) - if ("error" in config) throw new Error(config.error) - if (config.raw === undefined || !config.value.mcp || !(name in config.value.mcp)) return { name, removed: false } - if (config.comments) throw new Error(CONFIG_HAS_COMMENTS) - const backup = await backupConfig(target, config.raw) - delete config.value.mcp[name] - await writeAtomic(target, JSON.stringify(config.value, null, 2) + "\n") - return { name, removed: true, path: target, backup } - }) -} - -/** - * Reads the project's opencode.json. A missing file is not an error — it is a - * project that has never been configured — but an unparseable one is, because - * writing over it would destroy hand-written config. - * - * `comments` says the source carried them. Reading is unaffected; writing is - * not, because this module re-serialises the parsed value and would drop every - * comment on the way back out. - */ -async function readProjectConfig( - paths: FlowPaths, -): Promise<{ value: any; raw: string | undefined; comments: boolean } | { error: string }> { - const raw = await fs.readFile(path.join(paths.project, "opencode.json"), "utf8").catch(() => undefined) - if (raw === undefined) - return { value: { $schema: "https://opencode.ai/config.json" }, raw: undefined, comments: false } - const stripped = stripJsonc(raw) - const value = jsonOrUndefined(stripped.text) - if (value === undefined) return { error: "existing opencode.json is not valid JSON" } - return { value, raw, comments: stripped.comments } -} - -/** - * Reads opencode's *global* config — the one file that reaches a run. - * - * `.jsonc` is preferred when it is the file that exists, because opencode reads - * both and writing the override into the other one would leave it unread. A - * missing file is a machine that has never configured opencode, not an error. - */ -async function readGlobalConfig() { - const candidates = globalConfigCandidates() - for (const target of candidates) { - const raw = await fs.readFile(target, "utf8").catch(() => undefined) - if (raw === undefined) continue - const stripped = stripJsonc(raw) - const value = jsonOrUndefined(stripped.text) - if (value === undefined) return { target, error: `${target} is not valid JSON` } - return { target, value, raw, comments: stripped.comments } - } - return { - target: candidates[0], - value: { $schema: "https://opencode.ai/config.json" }, - raw: undefined, - comments: false, - } -} - -/** - * Where this package lives on disk, so the MCP server can be started from it. - * - * Found by locating the server file rather than by `import.meta.dir`, which is - * undefined once the vite plugin bundles this module — the two hosts (the vite - * plugin and `server.ts`) would otherwise disagree about where they are. Both - * run from `packages/flow`, and a repo-root launch is checked too, so the path - * is discovered rather than configured: a checkout that moves repoints itself - * on the next install instead of leaving a command aimed at nothing. - */ -async function packageRoot() { - const cwd = process.cwd() - const candidates = [cwd, path.join(cwd, "packages", "flow"), path.resolve(cwd, ".."), path.resolve(cwd, "..", "..")] - for (const root of candidates) { - if ( - await fs.stat(path.join(root, "mcp", "dispatch.ts")).then( - () => true, - () => false, - ) - ) - return root - } - return cwd -} - -/** - * An absolute path to the bun that can run `mcp/dispatch.ts`. - * - * `process.execPath` is only right when this host is itself running under bun — - * the vite dev server runs under node, and node cannot execute a `.ts` entry. - * So PATH is searched, with `.exe` on Windows because opencode spawns a local - * MCP server without a shell and PATHEXT is not applied for it. - * - * Falls back to the bare name rather than failing the install: on a POSIX host - * that is usually resolvable anyway, and a wrong command is visible in the - * config the panel just told the user about. - */ -async function bunPath() { - if (path.basename(process.execPath).toLowerCase().startsWith("bun")) return process.execPath - const names = process.platform === "win32" ? ["bun.exe", "bun.cmd", "bun"] : ["bun"] - for (const dir of (process.env.PATH ?? "").split(path.delimiter).filter(Boolean)) { - for (const name of names) { - const candidate = path.join(dir, name) - if ( - await fs.stat(candidate).then( - (entry) => entry.isFile(), - () => false, - ) - ) - return candidate - } - } - return "bun" -} - -/** Whether the global config starts OpenFlow's dispatch MCP server. */ -async function dispatchToolStatus() { - const root = await packageRoot() - const runtime = await bunPath() - const config = await readGlobalConfig() - if ("error" in config) return { path: config.target, present: false, current: false, root, error: config.error } - return { path: config.target, ...installed(config.value, root, runtime), root } -} - -/** - * Adds or removes the server in the global config, backing the file up first. - * - * `restart: true` is not advice: opencode reads config once at boot, so the - * tool does not exist for any card until `opencode serve` restarts. - */ -async function applyDispatchTool(wanted: boolean) { - const root = await packageRoot() - const runtime = await bunPath() - const config = await readGlobalConfig() - if ("error" in config) return { error: config.error, path: config.target } - const result = wanted ? install(config.value, root, runtime) : uninstall(config.value) - if (!result.changed) - return { path: config.target, ...installed(config.value, root, runtime), root, changed: false, restart: false } - if (config.comments) return { error: CONFIG_HAS_COMMENTS, path: config.target } - return serialize(config.target, async () => { - const backup = config.raw === undefined ? undefined : await backupConfig(config.target, config.raw) - await fs.mkdir(path.dirname(config.target), { recursive: true }) - await writeAtomic(config.target, JSON.stringify(result.value, null, 2) + "\n") - return { - path: config.target, - ...installed(result.value, root, runtime), - root, - changed: true, - backup, - restart: true, - } - }) -} - -/** Which OpenAI-compatible providers the global config already repackages. */ -async function repackageStatus() { - const config = await readGlobalConfig() - const available = Object.keys(COMPATIBLE_PROFILES) - if ("error" in config) return { path: config.target, applied: [], available, error: config.error } - return { path: config.target, applied: repackaged(config.value), available } -} - -/** - * Writes the override into the global config, backing the file up first. - * - * The engine reads config once at boot, so `restart: true` is not advice — the - * models stay unroutable until `opencode serve` is restarted. - */ -async function applyRepackage(ids: string[]) { - const config = await readGlobalConfig() - if ("error" in config) return { error: config.error, path: config.target } - const result = repackage(config.value, ids) - if (!result.changed.length) - return { path: config.target, changed: [], applied: repackaged(config.value), restart: false } - if (config.comments) - return { - error: `${config.target} has comments, and saving would strip them — remove the comments, or add the provider override there by hand`, - path: config.target, - } - return serialize(config.target, async () => { - const backup = config.raw === undefined ? undefined : await backupConfig(config.target, config.raw) - await fs.mkdir(path.dirname(config.target), { recursive: true }) - await writeAtomic(config.target, JSON.stringify(result.value, null, 2) + "\n") - return { path: config.target, changed: result.changed, applied: repackaged(result.value), backup, restart: true } +async function deleteMcpServer(file: string, name: string) { + return serialize(file, async () => { + const config = await readMcpFile(file) + const servers = config.value.mcpServers as Record | undefined + if (config.raw === undefined || !servers || !(name in servers)) return { name, removed: false } + const { [name]: _removed, ...remaining } = servers + const backup = await backupOnce(file, config.raw) + await writeAtomic(file, jsonText({ ...config.value, mcpServers: remaining }, config.raw)) + return { name, removed: true, path: file, backup } }) } -const CONFIG_HAS_COMMENTS = - "existing opencode.json has comments, and saving would strip them — remove the comments, or make this change in opencode.json by hand" - -/** - * Reads opencode's config dialect rather than strict JSON. - * - * `packages/core/src/config.ts` parses `opencode.json` with `jsonc-parser` and - * `allowTrailingComma`, so comments and a trailing comma are a perfectly valid - * config there. Rejecting them as "not valid JSON" fails every config write for - * a user whose config opencode itself is happy with: the agent merge fails, the - * MCP panel shows nothing, and a skill gets written but never registered. - * `jsonc-parser` is not reachable from this package, so the two extensions are - * stripped here instead, string-aware so a comma or a `//` inside a value is - * left alone. - */ -function stripJsonc(raw: string) { - let out = "" - let comments = false - let index = 0 - while (index < raw.length) { - const char = raw[index] - if (char === '"') { - const start = index - index++ - while (index < raw.length && raw[index] !== '"') index += raw[index] === "\\" ? 2 : 1 - index++ - out += raw.slice(start, index) - continue - } - if (char === "/" && raw[index + 1] === "/") { - comments = true - const end = raw.indexOf("\n", index) - index = end === -1 ? raw.length : end - continue - } - if (char === "/" && raw[index + 1] === "*") { - comments = true - const end = raw.indexOf("*/", index + 2) - index = end === -1 ? raw.length : end + 2 - continue - } - // Strings are consumed whole above, so reaching a closer here means `out` - // ends at a structural position and a comma sitting there is the trailing - // one opencode allows and `JSON.parse` does not. - if (char === "}" || char === "]") out = out.replace(/,\s*$/, "") - out += char - index++ - } - return { text: out, comments } +/** Serialises with the indentation the file already used, so a rewrite does not reflow the whole file. */ +function jsonText(value: unknown, original: string | undefined) { + const indent = original ? /^([ \t]+)"/m.exec(original)?.[1] : undefined + return JSON.stringify(value, null, indent ?? 2) + "\n" } /** Parse guard: `undefined` rather than a throw, for the callers that treat unparseable as a state. */ @@ -1186,33 +823,49 @@ function jsonOrUndefined(text: string): any { } } -/** Where OpenFlow-authored skills are registered from, relative to the project. */ -const SKILL_SOURCE = "./.openflow/skills" +type SkillRow = { name: string; description?: string; updated: number; legacy?: true } /** - * Lists the skills OpenFlow has authored under `.openflow/skills`, reading each - * folder's `SKILL.md` frontmatter. A folder without a readable `SKILL.md` is - * skipped rather than surfaced as a broken row. + * Lists the skills omp will load for the profile (`/skills/*\/SKILL.md`) + * followed by the ones older builds authored under `.openflow/skills`, which + * omp never sees any more and are marked `legacy`. A legacy skill that a + * profile skill of the same name has superseded is not listed twice. */ -async function listSkills(paths: FlowPaths) { - const entries = await fs.readdir(paths.skills, { withFileTypes: true }).catch(() => []) - const out = [] +async function listSkills(paths: FlowPaths, profile: string | undefined) { + const own = profile ? await skillsIn(path.join(profile, "skills"), false) : [] + const taken = new Set(own.map((row) => row.name)) + const legacy = (await skillsIn(paths.skills, true)).filter((row) => !taken.has(row.name)) + return [...own, ...legacy].sort((a, b) => b.updated - a.updated) +} + +/** + * Reads each folder's `SKILL.md` frontmatter. A folder without a readable + * `SKILL.md` is skipped rather than surfaced as a broken row; entries are not + * filtered to directories, because a skill folder may be a symlink. + */ +async function skillsIn(root: string, legacy: boolean): Promise { + const entries = await fs.readdir(root).catch(() => [] as string[]) + const out: SkillRow[] = [] for (const entry of entries) { - if (!entry.isDirectory()) continue - const file = path.join(paths.skills, entry.name, "SKILL.md") + const file = path.join(root, entry, "SKILL.md") const [raw, stat] = await Promise.all([ fs.readFile(file, "utf8").catch(() => undefined), fs.stat(file).catch(() => undefined), ]) if (raw === undefined || !stat) continue - const meta = parseSkillMarkdown(raw) - out.push({ name: entry.name, description: meta.description, updated: stat.mtimeMs }) + out.push({ + name: entry, + description: parseSkillMarkdown(raw).description, + updated: stat.mtimeMs, + ...(legacy ? { legacy: true as const } : {}), + }) } - return out.sort((a, b) => b.updated - a.updated) + return out } /** - * Reads one skill's frontmatter and body back for editing, or undefined if absent. + * Reads one skill's frontmatter and body back for editing, or undefined if + * absent. The profile's copy wins over a legacy one of the same name. * * `name` is the frontmatter name when the file carries one — that is the identity * the agent sees, and it can differ from the folder slug (`Summarize` in @@ -1220,37 +873,33 @@ async function listSkills(paths: FlowPaths) { * the frontmatter to the slug and silently rename the skill. `folder` carries the * slug for callers that need to address the skill over the API. */ -async function readSkill(paths: FlowPaths, name: string) { - const file = path.join(paths.skills, name, "SKILL.md") - const raw = await fs.readFile(file, "utf8").catch(() => undefined) - if (raw === undefined) return undefined - const meta = parseSkillMarkdown(raw) - return { - name: meta.name ?? name, - folder: name, - description: meta.description, - content: meta.content, - path: file, +async function readSkill(paths: FlowPaths, profile: string | undefined, name: string) { + const roots = [...(profile ? [{ root: path.join(profile, "skills"), legacy: false }] : []), { root: paths.skills, legacy: true }] + for (const { root, legacy } of roots) { + const file = path.join(root, name, "SKILL.md") + const raw = await fs.readFile(file, "utf8").catch(() => undefined) + if (raw === undefined) continue + const meta = parseSkillMarkdown(raw) + return { + name: meta.name ?? name, + folder: name, + description: meta.description, + content: meta.content, + path: file, + ...(legacy ? { legacy: true as const } : {}), + } } + return undefined } /** - * Writes `.openflow/skills//SKILL.md` and registers the folder as a skill - * source in the project `opencode.json` the first time — the server only scans - * `.opencode/skill`(`s`) on its own, so a skill kept under `.openflow` is invisible - * until this path is listed in the config's `skills.paths`. The frontmatter name - * defaults to the folder slug so the two never drift. - * - * A skill created here does not load until `opencode serve` restarts: the server - * reads its config and skill sources once at boot. + * Writes `/skills//SKILL.md`, which omp discovers on its own + * (`/skills/*\/SKILL.md`), so there is nothing to register. The + * frontmatter name defaults to the folder slug so the two never drift. omp + * loads a skill only at session start, so a new one reaches the next card. */ -async function writeSkill(paths: FlowPaths, name: string, body: any) { - // Registration first, on purpose: it is the step that can fail, and a - // `SKILL.md` written next to an unregistered source is a skill the agent can - // never see. Its failure rides back out on the response either way, rather - // than being skipped because an earlier line already reported success. - const source = await registerSkillSource(paths) - const dir = path.join(paths.skills, name) +async function writeSkill(profile: string, name: string, body: any) { + const dir = path.join(profile, "skills", name) await fs.mkdir(dir, { recursive: true }) const file = path.join(dir, "SKILL.md") await writeAtomic( @@ -1261,47 +910,11 @@ async function writeSkill(paths: FlowPaths, name: string, body: any) { content: typeof body?.content === "string" ? body.content : "", }), ) - return { name, path: file, ...source } + return { name, path: file } } /** - * Adds `SKILL_SOURCE` to the project config's `skills.paths` once. The config - * schema types `skills` as `{ paths?: string[]; urls?: string[] }` (see - * `ConfigSkillsV1.Info`) and the loader reads `skills.paths` — writing a bare - * array here would fail config validation and opencode hard-fails on invalid - * config, which would break every card run in the project. - * - * Idempotent: a config that already lists it is left untouched - * (`registered: false`, no backup), so re-saving a skill does not churn the - * file or its `.bak`. - */ -async function registerSkillSource(paths: FlowPaths) { - const target = path.join(paths.project, "opencode.json") - return serialize(target, async () => { - const config = await readProjectConfig(paths) - if ("error" in config) return { registered: false, error: config.error } - if (config.raw === undefined) { - config.value.skills = { paths: [SKILL_SOURCE] } - await writeAtomic(target, JSON.stringify(config.value, null, 2) + "\n") - return { registered: true, backup: undefined } - } - // A config written by an older build may hold a bare array here. Treat it as - // the paths list so this rewrites it into the shape the schema accepts, - // rather than spreading its indices into the object. - const legacy = Array.isArray(config.value.skills) - const block = legacy ? { paths: config.value.skills } : (config.value.skills ?? {}) - const current: string[] = Array.isArray(block.paths) ? block.paths : [] - if (!legacy && current.includes(SKILL_SOURCE)) return { registered: false } - if (config.comments) return { registered: false, error: CONFIG_HAS_COMMENTS } - const backup = await backupConfig(target, config.raw) - config.value.skills = { ...block, paths: current.includes(SKILL_SOURCE) ? current : [...current, SKILL_SOURCE] } - await writeAtomic(target, JSON.stringify(config.value, null, 2) + "\n") - return { registered: true, backup } - }) -} - -/** - * Serialises a skill to the `SKILL.md` shape opencode discovers: a small YAML + * Serialises a skill to the `SKILL.md` shape omp discovers: a small YAML * frontmatter block (`name`, and `description` when set) followed by the * markdown body. Frontmatter values are single-lined by the caller, so no YAML * quoting or escaping is needed here. @@ -1338,25 +951,19 @@ function oneLine(value: unknown): string | undefined { } /** - * Copies the config aside before a merge rewrites it, without ever losing the - * pre-OpenFlow original. - * - * `.bak` is written once and then left alone: it is the config as it was before - * OpenFlow first touched it. Every later merge writes `.prev.bak` instead, so - * undoing the last merge stays possible too. Overwriting `.bak` on every merge - * — which is what this used to do — means the second merge replaces the - * original with an already-merged copy and the real config is gone, and it is - * gone quietly, since a project's `opencode.json` is usually gitignored. + * Copies a config aside before it is rewritten, once. `.bak` is the file as it + * was before OpenFlow first touched it and is never overwritten: every later + * rewrite would otherwise replace the original with an already-edited copy, + * quietly. Returns the backup's path, or undefined when there was nothing to + * back up. */ -export async function backupConfig(target: string, raw: string) { - const original = `${target}.bak` - const exists = await fs - .access(original) - .then(() => true) - .catch(() => false) - const file = exists ? `${target}.prev.bak` : original - await writeAtomic(file, raw) - return file +async function backupOnce(target: string, raw: string | undefined) { + if (raw === undefined) return undefined + const backup = `${target}.bak` + await fs.writeFile(backup, raw, { flag: "wx" }).catch((error) => { + if (error?.code !== "EEXIST") throw error + }) + return backup } /** @@ -1407,10 +1014,10 @@ function pipelineConflict(name: string) { * A rename within one directory is atomic on NTFS and on POSIX, so a crash, a * power loss, or a full disk leaves either the whole old file or the whole new * one — never the truncated middle a plain `writeFile` can leave behind. That - * matters most for `opencode.json`, which an agent merge rewrites on *every* - * run and which opencode hard-fails on when it cannot be parsed: one bad write - * bricks every card in the project. Beside the target rather than in the OS - * temp directory, because a cross-volume rename is a copy and is not atomic. + * matters most for `mcp.json`, which a profile's every card session reads at + * start: one truncated write there breaks them all. Beside the target rather + * than in the OS temp directory, because a cross-volume rename is a copy and + * is not atomic. */ async function writeAtomic(file: string, data: string) { const temp = `${file}.${process.pid.toString(36)}${Date.now().toString(36)}.tmp` @@ -1434,10 +1041,9 @@ async function writeAtomic(file: string, data: string) { /** * Runs `work` only after every earlier call for the same `key` has finished. * - * Four routes read-modify-write `opencode.json` — an agent merge (which runs on - * every click of Run), an MCP upsert, an MCP delete, and a skill registration — - * and running any two of them interleaved means both read the same pre-state - * and the second write silently discards the first. Each of them re-reads the + * Read-modify-write routes (an MCP upsert, an MCP delete, the roles list, the + * runs index) run two at a time interleaved: both read the same pre-state and + * the second write silently discards the first. Each of them re-reads the * file *inside* its turn here rather than before it, which is what makes the * serialisation worth anything. * diff --git a/packages/flow/lib/zen.test.ts b/packages/flow/lib/zen.test.ts deleted file mode 100644 index d4e1c26b70..0000000000 --- a/packages/flow/lib/zen.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { parseZenModels, resetZenCache, zenModels } from "./zen" - -const real = globalThis.fetch - -function stub(handler: () => Promise | Response) { - globalThis.fetch = (() => Promise.resolve(handler())) as unknown as typeof fetch -} - -function list(ids: string[]) { - return new Response(JSON.stringify({ object: "list", data: ids.map((id) => ({ id, object: "model" })) }), { - status: 200, - headers: { "content-type": "application/json" }, - }) -} - -afterEach(() => { - globalThis.fetch = real - resetZenCache() -}) - -describe("parseZenModels", () => { - test("reads the OpenAI-shaped list", () => { - expect(parseZenModels({ object: "list", data: [{ id: "gpt-5" }, { id: "kimi-k3" }] })).toEqual(["gpt-5", "kimi-k3"]) - }) - - test("ignores entries without a usable id rather than yielding empty strings", () => { - expect(parseZenModels({ data: [{ id: "gpt-5" }, {}, { id: 7 }, { id: "" }, null] })).toEqual(["gpt-5"]) - }) - - test("keeps zen's paid ids alongside the free ones", () => { - // The list answers "does zen still serve this", nothing more. Both halves - // have to survive: the free ids so a keyless install can run something, the - // paid ids so a stored zen key still reaches what it paid for. Deciding - // which is which is `isFreeModel`'s job, not the list's. - expect(parseZenModels({ data: [{ id: "claude-opus-5" }, { id: "nemotron-3.5-lightning-free" }] })).toEqual([ - "claude-opus-5", - "nemotron-3.5-lightning-free", - ]) - }) - - test("anything that is not a list reads as no answer at all", () => { - expect(parseZenModels({})).toEqual([]) - expect(parseZenModels({ data: "nope" })).toEqual([]) - expect(parseZenModels(undefined)).toEqual([]) - }) -}) - -describe("zenModels", () => { - test("returns what zen serves", async () => { - stub(() => list(["gpt-5", "claude-opus-5"])) - expect(await zenModels()).toEqual(["gpt-5", "claude-opus-5"]) - }) - - test("caches, then re-reads once the window has passed", async () => { - let calls = 0 - stub(() => { - calls += 1 - return list([`model-${calls}`]) - }) - const first = await zenModels({ now: 0 }) - expect(await zenModels({ now: 60_000 })).toEqual(first) - expect(calls).toBe(1) - expect(await zenModels({ now: 11 * 60_000 })).toEqual(["model-2"]) - }) - - test("a network failure answers undefined, never an empty list", async () => { - // Undefined leaves the catalog alone; an empty list would delete every zen - // model from the picker the moment opencode.ai is unreachable. - stub(() => { - throw new Error("offline") - }) - expect(await zenModels()).toBeUndefined() - }) - - test("an error status answers undefined too", async () => { - stub(() => new Response("nope", { status: 503 })) - expect(await zenModels()).toBeUndefined() - }) - - test("a failure after a good read keeps serving the last known list", async () => { - stub(() => list(["gpt-5"])) - await zenModels({ now: 0 }) - stub(() => { - throw new Error("offline") - }) - expect(await zenModels({ now: 11 * 60_000 })).toEqual(["gpt-5"]) - }) - - test("an empty list is treated as no answer — zen always serves something", async () => { - stub(() => list([])) - expect(await zenModels()).toBeUndefined() - }) -}) diff --git a/packages/flow/lib/zen.ts b/packages/flow/lib/zen.ts deleted file mode 100644 index 273dbcd919..0000000000 --- a/packages/flow/lib/zen.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * What opencode zen actually serves, as opposed to what the catalog claims. - * - * The `opencode` provider's models come from models.dev, which lags: on - * 2026-08-13 the catalog listed 90 zen models and `GET /zen/v1/models` served - * 61. The other 29 are not "unavailable to this account" — they are gone, and - * every one of them answers a run with - * - * HTTP 401 {"type":"error","error":{"type":"ModelError", - * "message":"Model kimi-k2 is not supported"}} - * - * about 1.4s in. That is the first thing a new user meets, since zen is the - * provider they are most likely to connect first. - * - * The list is public — no key, no account — so the flow host can read it - * directly. It is the only provider that allows this: everyone else's `/models` - * needs the credential, which lives in the opencode server's store and never - * reaches OpenFlow. - * - * Public to read is not free to run. The same list carries zen's paid models - * (claude-*, gpt-5*, gemini-*, glm-*, kimi-*) beside the `-free` ids, and a - * stored zen key unlocks the paid ones with real billing attached. So being on - * this list only means "zen still serves it"; whether it costs money is decided - * by `isFreeModel` in [../src/server/providers.ts], never here. - */ -const ENDPOINT = "https://opencode.ai/zen/v1/models" -const TTL = 10 * 60_000 -const TIMEOUT = 5_000 - -let cache: { ids: string[]; at: number } | undefined - -/** - * Model ids zen currently serves, or undefined when the list cannot be read. - * - * Undefined is not an empty list and callers must not treat it as one: failing - * to reach opencode.ai has to leave the catalog alone, or an offline user loses - * every zen model at once. Wrong in the direction of showing a dead model beats - * wrong in the direction of hiding a live one. - */ -export async function zenModels(options: { now?: number } = {}): Promise { - const now = options.now ?? Date.now() - if (cache && now - cache.at < TTL) return cache.ids - try { - const response = await fetch(ENDPOINT, { - headers: { accept: "application/json" }, - signal: AbortSignal.timeout(TIMEOUT), - }) - if (!response.ok) return cache?.ids - const ids = parseZenModels(await response.json()) - if (!ids.length) return cache?.ids - cache = { ids, at: now } - return ids - } catch { - // Stale beats nothing: a list read ten minutes ago is still closer to the - // truth than models.dev's copy. - return cache?.ids - } -} - -/** OpenAI-shaped `{ object: "list", data: [{ id }] }`. */ -export function parseZenModels(body: unknown): string[] { - const data = (body as { data?: unknown })?.data - if (!Array.isArray(data)) return [] - return data - .map((item) => (item as { id?: unknown })?.id) - .filter((id): id is string => typeof id === "string" && id.length > 0) -} - -/** Test seam — the cache is process-wide and outlives a single request. */ -export function resetZenCache() { - cache = undefined -} diff --git a/packages/flow/mcp/dispatch.ts b/packages/flow/mcp/dispatch.ts deleted file mode 100644 index 8c67c2dccf..0000000000 --- a/packages/flow/mcp/dispatch.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * The MCP server that lets an orchestrator card hand work out. - * - * Why this exists: an orchestrator used to say what it wanted by ending its - * message with a fenced JSON block, and measured against real providers that - * loses three ways. A model sends the JSON with no fence. A model emits the - * block and then keeps calling tools, so the block is no longer in the message - * OpenFlow reads. And a model that is good at instructions tries to *call* - * `dispatch` as a tool, because that is what the schema in front of it looks - * like. The last one is the tell: models want this to be a tool, so it is one. - * - * The tools record nothing. OpenFlow reads the call back out of the session's - * own message history, which is the authoritative record the server persists — - * the same reason usage is reconciled from history rather than from the event - * bus. So this process is a schema and a nudge to stop talking, nothing more, - * and it holds no state that could disagree with the run. - * - * Hand-rolled JSON-RPC over stdio rather than the MCP SDK: `packages/flow` may - * add no dependency (`bun.lock` gains only the workspace entry), and the three - * methods a tools-only server needs are short enough to own. - */ - -type Request = { jsonrpc: "2.0"; id?: number | string; method: string; params?: Record } - -const NAME = "openflow" -const VERSION = "1.0.0" -/** Spoken when the client asks for something we do not recognise. */ -const FALLBACK_PROTOCOL = "2025-06-18" - -const TOOLS = [ - { - name: "dispatch", - description: - "Hand work to the cards below you. Every card named runs at the same time, so only batch work that does not depend on itself. Call this once, then stop and end your turn — their answers arrive in your next message. Do not call any other tool in the same turn.", - inputSchema: { - type: "object", - properties: { - assignments: { - type: "array", - minItems: 1, - description: "One entry per card you are dispatching. A card may appear only once.", - items: { - type: "object", - properties: { - card: { type: "string", description: "The card id, exactly as listed in your briefing." }, - task: { - type: "string", - description: - "What that card must do, in full. It has not seen the run task, the other cards' answers, or anything you dispatched before.", - }, - files: { - type: "array", - items: { type: "string" }, - description: - "Optional: the files this card is expected to create or change. Cards in a batch run at once and nothing locks a file, so a batch in which two cards declare the same file is refused before either runs.", - }, - }, - required: ["card", "task"], - }, - }, - }, - required: ["assignments"], - }, - }, - { - name: "finish", - description: - "Answer the run. Call this when you can write the result from what you have, then stop and end your turn. The text you pass is the whole result of the run.", - inputSchema: { - type: "object", - properties: { - answer: { type: "string", description: "The answer to the run's task, written for the person who started it." }, - }, - required: ["answer"], - }, - }, -] - -/** - * What a tool call answers with. - * - * The point of the wording is to end the turn. A model that gets "ok" back - * keeps working, and the next thing OpenFlow reads is whatever it did after — - * which is exactly the failure the fenced block had. - */ -function result(text: string) { - return { content: [{ type: "text", text }] } -} - -function handle(request: Request) { - switch (request.method) { - case "initialize": - return { - protocolVersion: - typeof request.params?.protocolVersion === "string" ? request.params.protocolVersion : FALLBACK_PROTOCOL, - capabilities: { tools: {} }, - serverInfo: { name: NAME, version: VERSION }, - } - case "tools/list": - return { tools: TOOLS } - case "tools/call": { - const tool = request.params?.name - if (tool === "dispatch") - return result("Dispatched. Stop here and end your turn now — say nothing further and call no other tool. What the cards return will arrive in your next message.") - if (tool === "finish") - return result("Answer recorded. Stop here and end your turn now — say nothing further and call no other tool.") - return { isError: true, content: [{ type: "text", text: `Unknown tool: ${String(tool)}` }] } - } - case "ping": - return {} - default: - return undefined - } -} - -/** - * Line-delimited JSON in, line-delimited JSON out. - * - * A method we do not know gets a `-32601` rather than silence: a client left - * waiting on a response that never comes hangs the session that owns it, which - * would surface as a card that runs until its timeout. - */ -async function main() { - const decoder = new TextDecoder() - let buffer = "" - for await (const chunk of Bun.stdin.stream()) { - buffer += decoder.decode(chunk, { stream: true }) - let newline = buffer.indexOf("\n") - while (newline >= 0) { - const line = buffer.slice(0, newline).trim() - buffer = buffer.slice(newline + 1) - newline = buffer.indexOf("\n") - if (!line) continue - - let request: Request - try { - request = JSON.parse(line) as Request - } catch { - Bun.write(Bun.stdout, JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }) + "\n") - continue - } - // A notification carries no id and takes no reply — `notifications/initialized` - // is the one every client sends, and answering it is a protocol error. - if (request.id === undefined) continue - - const payload = handle(request) - const response = - payload === undefined - ? { jsonrpc: "2.0", id: request.id, error: { code: -32601, message: `unknown method: ${request.method}` } } - : { jsonrpc: "2.0", id: request.id, result: payload } - Bun.write(Bun.stdout, JSON.stringify(response) + "\n") - } - } -} - -await main() diff --git a/packages/flow/package.json b/packages/flow/package.json index 8f07555101..dec27a6c02 100644 --- a/packages/flow/package.json +++ b/packages/flow/package.json @@ -16,12 +16,15 @@ "evals": "bun evals/score.ts" }, "dependencies": { - "@opencode-ai/sdk": "workspace:*", - "solid-js": "catalog:" + "@lydell/node-pty": "catalog:", + "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", + "solid-js": "catalog:", + "ws": "8.21.0" }, "devDependencies": { "@types/bun": "catalog:", "@types/node": "catalog:", + "@types/ws": "8.18.1", "@typescript/native-preview": "catalog:", "typescript": "catalog:", "vite": "catalog:", diff --git a/packages/flow/server.ts b/packages/flow/server.ts index d9cf60a027..2fe494845d 100644 --- a/packages/flow/server.ts +++ b/packages/flow/server.ts @@ -1,10 +1,11 @@ import path from "node:path" import { fileURLToPath } from "node:url" -import { existsSync } from "node:fs" import { remoteBindRefusal } from "./lib/guard" -import { serveCommand, supervisorFor } from "./lib/opencode-process" +import { createHost } from "./lib/host" import { resolveProject } from "./lib/last-session" import { flowPaths, handleFlow } from "./lib/store" +import type { OmpStatusView } from "./shared/omp" +import { bunWebSocket } from "./lib/terminal/ws-bun" // Shared with the vite dev host so both explain a held port the same way. import { portInUse } from "./vite/port-in-use" @@ -12,15 +13,14 @@ import { portInUse } from "./vite/port-in-use" * Serves a built OpenFlow (`bun run build`) without vite. * * The dev server provides three things the static bundle cannot: the - * `/flow/api` store, a proxy to `opencode serve` that keeps the browser - * same-origin, and an SSE path that is never buffered. Without them a built - * `dist/` is a dead page — every save, load and run 404s. This is the same - * three things, standing on their own. + * `/flow/api` store, the host modules that drive Omp (engine, uplift, memory, + * swarm, settings, terminal) and an SSE path that is never buffered. Without + * them a built `dist/` is a dead page — every save, load and run 404s. This is + * the same things, standing on their own. */ const root = path.dirname(fileURLToPath(import.meta.url)) const dist = path.join(root, "dist") -const upstream = process.env.OPENCODE_SERVER_URL ?? "http://127.0.0.1:4096" // OPENFLOW_PROJECT wins, then the folder last switched to in the UI, then // this repo. See `lib/last-session.ts`. const project = resolveProject(path.resolve(root, "../../")) @@ -28,42 +28,13 @@ const port = Number(process.env.FLOW_PORT ?? 5174) const hostname = process.env.FLOW_HOST ?? "127.0.0.1" const paths = flowPaths(project) - -// The repo this checkout lives in — where the engine can be run from source. -const repo = path.resolve(root, "../../") -/** - * Who owns `opencode serve`. - * - * With FLOW_MANAGE_SERVER=1 this host spawns it and the canvas can restart it, - * which is what makes a merged agent, a new skill or an MCP change take effect - * without leaving the app. Without it — the default — nothing is spawned and - * the restart route reports the command instead. See `lib/opencode-process.ts`. - */ -const engine = await supervisorFor( - serveCommand({ - repo, - url: upstream, - hasSource: existsSync(path.join(repo, "packages", "opencode", "src", "index.ts")), - }), - process.env, - (line) => console.log(`opencode ${line}`), -) - -/** Paths that belong to `opencode serve` rather than to this app. */ -const PROXIED = ["/api", "/global", "/event", "/mcp"] -/** Paths that stream and are meant to stay open — never given a deadline. */ -const STREAMED = ["/event"] -/** - * How long a proxied request may hang before this host gives up on it. - * - * Restarting the engine is what makes this necessary: a keep-alive connection - * to the process that just died is never answered and never refused, so an - * unbounded request hangs the canvas on a call that can only fail. 504 with a - * reason is the honest answer. - */ -const PROXY_TIMEOUT = Number(process.env.FLOW_PROXY_TIMEOUT ?? 30_000) const MAX_BODY = 8 * 1024 * 1024 +// `paths.project` is the live object `lib/store.ts` mutates when the canvas +// switches folders, so the host follows the switch without a restart. +const host = createHost({ project: () => paths.project, allowRemote: process.env.FLOW_ALLOW_REMOTE === "1" }) +const terminal = bunWebSocket(host.terminal) + if (!(await Bun.file(path.join(dist, "index.html")).exists())) { console.error(`no build found at ${dist}\nrun: bun run --cwd packages/flow build`) process.exit(1) @@ -77,23 +48,24 @@ if (refusal) { const server = listen() -// Bring the engine up before announcing the canvas, so the first page load -// never talks to a server that is not there yet. Unmanaged hosts no-op here. -const boot = await engine.ensure().catch((error) => { - console.error(`opencode could not start the engine: ${error instanceof Error ? error.message : error}`) - return undefined -}) -if (boot?.managed) { - const stop = () => { - void engine.stop().finally(() => process.exit(0)) - } - process.on("SIGINT", stop) - process.on("SIGTERM", stop) -} +// Ctrl+C or a service stop must not orphan an omp process, planner or shell the +// host spawned. `exit` covers every other way out; the modules also reap their +// own children, so this is the orderly path, not the only one. +const stop = () => void host.dispose().finally(() => process.exit(0)) +process.on("SIGINT", stop) +process.on("SIGTERM", stop) +process.once("exit", () => void host.dispose()) + +const omp = await host.handle(new Request(`http://${hostname}/flow/api/omp/status`)) +const status = (await omp?.json()) as OmpStatusView | undefined +const problem = status?.problems[0] +const ompLine = status?.available + ? status.version + : `not found: ${problem ? `${problem.message} ${problem.fix ?? ""}`.trim() : "omp is not available"}` console.log(`openflow http://${hostname}:${server.port}`) -console.log(`opencode ${upstream}`) console.log(`project ${paths.project}`) +console.log(`omp ${ompLine}`) /** * Binds the canvas port, or says what is holding it. @@ -109,13 +81,16 @@ function listen() { port, hostname, idleTimeout: 0, // SSE streams outlive any request timeout - async fetch(request) { - const url = new URL(request.url) + async fetch(request, serving) { + // The terminal's WebSocket upgrade must be decided before anything reads the request. + const upgraded = terminal.fetch(request, serving) + if (upgraded instanceof Response) return upgraded + if (upgraded) return undefined - if (PROXIED.some((prefix) => url.pathname === prefix || url.pathname.startsWith(`${prefix}/`))) { - return proxy(request, url) - } + const hosted = await host.handle(request) + if (hosted) return hosted + const url = new URL(request.url) if (url.pathname === "/flow/api" || url.pathname.startsWith("/flow/api/")) { try { const result = await handleFlow(paths, { @@ -123,8 +98,7 @@ function listen() { path: url.pathname, search: url.searchParams, json: () => body(request), - upstream, - serve: engine, + profileDir: host.profileDir, }) if (result) return Response.json(result.body, { status: result.status }) return Response.json({ error: "not found" }, { status: 404 }) @@ -135,6 +109,7 @@ function listen() { return statics(url) }, + websocket: terminal.websocket, }) } catch (error) { // Bun reports the code on some builds and only the sentence on others. @@ -144,79 +119,6 @@ function listen() { } } -/** Forwards a request to `opencode serve`, streaming the response back untouched. */ -async function proxy(request: Request, url: URL) { - const headers = new Headers(request.headers) - headers.delete("host") - const streamed = STREAMED.some((prefix) => url.pathname === prefix || url.pathname.startsWith(`${prefix}/`)) - const deadline = streamed ? undefined : AbortSignal.timeout(PROXY_TIMEOUT) - const signal = deadline ? AbortSignal.any([request.signal, deadline]) : request.signal - try { - return relay( - await fetch(new URL(url.pathname + url.search, upstream), { - method: request.method, - headers, - body: request.body, - signal, - redirect: "manual", - // Required by Bun and undici whenever a request carries a stream body. - duplex: "half", - } as RequestInit), - ) - } catch (error) { - // A GET that timed out is almost always a keep-alive socket to an engine - // that has been restarted underneath it. One retry on a fresh connection - // is the difference between a working canvas and a dead one; anything with - // a body is left alone, because a retry there could repeat a side effect. - if (deadline?.aborted && request.method === "GET") { - const retry = new Headers(headers) - retry.set("connection", "close") - const answer = await fetch(new URL(url.pathname + url.search, upstream), { - method: "GET", - headers: retry, - signal: AbortSignal.timeout(PROXY_TIMEOUT), - redirect: "manual", - }).catch(() => undefined) - if (answer) return relay(answer) - } - // `message`, not `error`: this body is read by the SDK client in the - // browser, whose `describe()` renders `message` as a sentence and anything - // else as raw JSON. The dev proxy answers the same key — the two hosts - // must not drift, or the same failure reads differently in each. - if (deadline?.aborted) { - return Response.json( - { - message: `opencode serve at ${upstream} did not answer within ${PROXY_TIMEOUT / 1000}s — it may have been restarted; retry`, - }, - { status: 504 }, - ) - } - return Response.json( - { message: `cannot reach opencode serve at ${upstream}: ${error instanceof Error ? error.message : error}` }, - { status: 502 }, - ) - } -} - -/** - * Hands an upstream response back to the browser. - * - * `fetch` transparently decompresses, so the body reaching us is plain text - * while the upstream headers still claim `content-encoding: gzip` and a - * compressed `content-length`. Forwarding those verbatim makes the browser try - * to gunzip plaintext — `ERR_CONTENT_DECODING_FAILED`, and on a large body - * (the ~600-model catalog) a request that simply never settles. The engine's - * own answer is kept; only the two headers that no longer describe this body - * are dropped. - */ -function relay(response: Response) { - if (!response.headers.has("content-encoding")) return response - const headers = new Headers(response.headers) - headers.delete("content-encoding") - headers.delete("content-length") - return new Response(response.body, { status: response.status, statusText: response.statusText, headers }) -} - async function body(request: Request) { const length = Number(request.headers.get("content-length") ?? 0) if (length > MAX_BODY) throw new Error("request body too large") diff --git a/packages/flow/vite.config.ts b/packages/flow/vite.config.ts index aeb9df5c55..7f0f48eaf8 100644 --- a/packages/flow/vite.config.ts +++ b/packages/flow/vite.config.ts @@ -7,7 +7,7 @@ import { portInUse } from "./vite/port-in-use" /** * The canvas port. Fixed, and `strictPort` below keeps it that way — every - * README, the launcher and the dev proxy all name this one URL. + * README and the launcher name this one URL. * * The dev server binds the *name* `localhost`, so `http://127.0.0.1:5174` * refuses the connection even while the server is up. Print the working URL. @@ -15,62 +15,15 @@ import { portInUse } from "./vite/port-in-use" const PORT = 5174 const CANVAS_URL = `http://localhost:${PORT}` -// The opencode server OpenFlow drives. Start it with `opencode serve`. -const server = process.env.OPENCODE_SERVER_URL ?? "http://127.0.0.1:4096" - // The project OpenFlow orchestrates: where `.openflow/` lives and where agent // sessions run. OPENFLOW_PROJECT wins, then the folder last switched to in the // UI, then the repo root two levels up from packages/flow. const project = resolveProject(fileURLToPath(new URL("../../", import.meta.url))) -/** - * How long a proxied request may hang before the dev server gives up. - * - * Restarting `opencode serve` is the case this exists for: a pooled keep-alive - * connection to the process that just died never answers and never errors, so - * without a bound the canvas waits forever on a request that can only fail. - * SSE keeps its own config below — a stream is *meant* to stay open. - */ -const PROXY_TIMEOUT = Number(process.env.FLOW_PROXY_TIMEOUT ?? 30_000) - -const proxy = { - target: server, - changeOrigin: true, - ws: false, - timeout: PROXY_TIMEOUT, - proxyTimeout: PROXY_TIMEOUT, - /** - * Say why the upstream call failed instead of letting http-proxy answer a - * bare 500 with an empty body. - * - * `opencode serve` not running is the single most common way OpenFlow - * breaks, and an empty 500 reaches the browser as `cannot reach opencode - * serve: {}` — the SDK has no body to build a message from. A JSON body with - * a `message` field is what every error path here already knows how to read. - */ - configure: (instance: any) => { - instance.on("error", (error: NodeJS.ErrnoException, _request: any, socket: any) => { - const reason = - error.code === "ECONNREFUSED" || error.code === "ECONNRESET" - ? // `opencode serve` as a bare command only resolves when the packaged - // binary is on PATH, which it is not in a checkout — and this proxy - // only ever runs from one. `bun openflow.ts` is what every other - // surface tells the user, and it works here. - `cannot reach opencode serve at ${server} — start it with \`bun openflow.ts\` from the repo root` - : `opencode serve request failed: ${error.message}` - // On an upgrade/socket error there is no ServerResponse to write to. - if (!socket || typeof socket.writeHead !== "function") return socket?.destroy?.() - if (socket.headersSent) return socket.end() - socket.writeHead(502, { "content-type": "application/json" }) - socket.end(JSON.stringify({ message: reason })) - }) - }, -} - export default defineConfig({ plugins: [ solid(), - flowStore({ project, upstream: server }), + flowStore({ project }), { // `strictPort` makes vite exit on a held port with a bare EADDRINUSE // stack and nothing else — no browser ever opens, so the terminal is the @@ -88,14 +41,6 @@ export default defineConfig({ server: { port: PORT, strictPort: true, - proxy: { - "/api": proxy, - "/global": proxy, - // SSE streams must not be buffered or timed out. - "/event": { ...proxy, timeout: 0, proxyTimeout: 0 }, - // MCP routes are the one instance API not served under /api. - "/mcp": proxy, - }, }, build: { target: "esnext", diff --git a/packages/flow/vite/flow-store.ts b/packages/flow/vite/flow-store.ts index ecc86a2667..327ac1c5b1 100644 --- a/packages/flow/vite/flow-store.ts +++ b/packages/flow/vite/flow-store.ts @@ -1,44 +1,20 @@ -import { existsSync } from "node:fs" -import path from "node:path" import type { Connect, Plugin, ViteDevServer } from "vite" import { remoteBindRefusal } from "../lib/guard" -import { serveCommand, supervisorFor, type Supervisor } from "../lib/opencode-process" +import { createHost } from "../lib/host" +import { nodeMiddleware } from "../lib/host/node-adapter" import { flowPaths, handleFlow } from "../lib/store" +import { attachNodeUpgrade } from "../lib/terminal/ws-node" /** - * Mounts OpenFlow's persistence on the vite dev server. + * Mounts OpenFlow's persistence and its host modules on the vite dev server. * - * The routes themselves live in `lib/store.ts` and are shared with the + * The routes themselves live in `lib/store.ts` (the store) and `lib/host` + * (engine, uplift, memory, swarm, Omp settings, terminal), both shared with the * standalone server, so `bun dev` and a built deployment cannot drift into two - * different stores. + * different hosts. */ -export function flowStore(options: { project: string; upstream?: string; repo?: string }): Plugin { +export function flowStore(options: { project: string }): Plugin { const paths = flowPaths(options.project) - const upstream = options.upstream ?? process.env.OPENCODE_SERVER_URL ?? "http://127.0.0.1:4096" - const repo = options.repo ?? path.resolve(process.cwd(), "../../") - - /** - * The engine's process, when FLOW_MANAGE_SERVER=1 asks this host to own it. - * Resolved lazily and once: vite calls `configureServer` synchronously, and - * deciding ownership means asking the port whether an engine is already - * there. See `lib/opencode-process.ts`. - */ - let engine: Promise | undefined - const supervisor = () => - (engine ??= supervisorFor( - serveCommand({ - repo, - url: upstream, - hasSource: existsSync(path.join(repo, "packages", "opencode", "src", "index.ts")), - }), - process.env, - (line) => console.log(`[openflow] opencode ${line}`), - ).then(async (made) => { - // A managed engine is started with the dev server, the same way the - // standalone host does it, so `bun dev` alone is enough to run OpenFlow. - await made.ensure().catch((error) => console.error(`[openflow] ${error?.message ?? error}`)) - return made - })) return { name: "openflow-store", @@ -48,8 +24,23 @@ export function flowStore(options: { project: string; upstream?: string; repo?: const refusal = remoteBindRefusal({ host: server.config.server.host, project: paths.project }) if (refusal) throw new Error(refusal) - // Ctrl+C on vite must not orphan an engine this host spawned. - server.httpServer?.on("close", () => void engine?.then((made) => made.stop())) + // `paths.project` is the live object `lib/store.ts` mutates when the canvas switches folders. + const host = createHost({ project: () => paths.project, allowRemote: process.env.FLOW_ALLOW_REMOTE === "1" }) + + // Ctrl+C on vite must not orphan an omp process, planner or shell. A hard + // kill skips 'close'; the modules' own process hooks cover that. + // Vite hands out an HTTP/2 server when https is on and no proxy is set; it never emits 'upgrade'. + const http = server.httpServer + const detach = http && !("updateSettings" in http) ? attachNodeUpgrade(http, host.terminal) : undefined + http?.on("close", () => { + detach?.() + void host.dispose() + }) + process.once("exit", () => void host.dispose()) + + // Before the store: the host owns `/flow/api/{engine,omp,uplift,memory,swarm,pty}` + // and answers `undefined` for everything else, which falls through. + server.middlewares.use("/flow", nodeMiddleware(host.handle)) server.middlewares.use("/flow/api", async (req, res, next) => { try { @@ -59,8 +50,7 @@ export function flowStore(options: { project: string; upstream?: string; repo?: path: url.pathname, search: url.searchParams, json: () => read(req), - upstream, - serve: await supervisor(), + profileDir: host.profileDir, }) if (!result) return next() json(res, result.status, result.body) From 26c087f08a82ed3cc29164c485aab89e8d79351e Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:59:28 +0000 Subject: [PATCH 09/49] feat(flow): copy omp logins into the openflow profile A fresh isolated profile has no logins. The Status tab now offers one confirmed action that pipes omp auth export from the default profile into omp auth import for the OpenFlow profile; nothing is written to disk or echoed. Refs SPE-16064 --- packages/flow/lib/omp-config/auth.test.ts | 232 ++++++++++++++++++ packages/flow/lib/omp-config/auth.ts | 80 ++++++ packages/flow/lib/omp-config/config.ts | 95 +++++-- packages/flow/lib/omp-config/fake-omp.ts | 34 ++- packages/flow/lib/omp-config/index.ts | 14 +- packages/flow/lib/omp-config/routes.ts | 19 +- packages/flow/lib/omp-config/status.test.ts | 1 + packages/flow/lib/omp-config/status.ts | 2 +- packages/flow/shared/omp.ts | 5 + packages/flow/src/server/omp-config.test.ts | 17 ++ packages/flow/src/server/omp-config.ts | 4 + .../flow/src/ui/omp-settings-model.test.ts | 30 +++ packages/flow/src/ui/omp-settings-model.ts | 23 ++ packages/flow/src/ui/omp-settings.css | 4 + packages/flow/src/ui/omp-settings.tsx | 84 ++++++- 15 files changed, 616 insertions(+), 28 deletions(-) create mode 100644 packages/flow/lib/omp-config/auth.test.ts create mode 100644 packages/flow/lib/omp-config/auth.ts diff --git a/packages/flow/lib/omp-config/auth.test.ts b/packages/flow/lib/omp-config/auth.test.ts new file mode 100644 index 0000000000..104aa077d3 --- /dev/null +++ b/packages/flow/lib/omp-config/auth.test.ts @@ -0,0 +1,232 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { OMP_ROUTES, type OmpAuthImportResult } from "../../shared/omp" +import { resetOmpCheck } from "../omp/binary" +import type { LaunchSpec, OmpProcess } from "../omp/rpc-types" +import { installFakeOmp } from "./fake-omp" +import { createOmpConfig } from "./index" +import { isRecord } from "./settings" + +const SECRETS = ["sk-ant-oat01-AAAA1111bbbb2222cccc3333dddd", "refresh-9f8e7d6c5b4a39281706f5e4d3c2b1a0", "zai-key-QQQQ5555rrrr6666ssss7777"] +const EXPORT = `${JSON.stringify( + { + version: 1, + credentials: [ + { provider: "anthropic", credential: { type: "oauth", access: SECRETS[0], refresh: SECRETS[1] } }, + { provider: "zai", credential: { type: "api_key", key: SECRETS[2], note: "café ✓" } }, + ], + }, + null, + 2, +)}\n` + +type Call = { argv: string[]; leaked: string[] } + +let dir: string +let bin: string +let server: { port?: number; stop(closeActive?: boolean): Promise } | undefined +let spawns: number + +function start(runTimeoutMs?: number) { + const module = createOmpConfig({ + stateDir: () => path.join(dir, "state"), + env: { PATH: bin }, + runTimeoutMs, + launch: (input) => ({ argv: [input.binary], env: {}, cwd: input.session.directory, files: [] }) satisfies LaunchSpec, + spawn: () => { + spawns++ + return { + ready: Promise.resolve({ protocolVersion: 2 }), + async command() { + return { providers: [{ id: "anthropic", name: "Anthropic", available: true, authenticated: true }] } as never + }, + async close() {}, + } as Partial as OmpProcess + }, + }) + server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(request) { + return (await module.handle(request)) ?? new Response("unhandled", { status: 418 }) + }, + }) +} + +beforeEach(async () => { + resetOmpCheck() + dir = await mkdtemp(path.join(os.tmpdir(), "omp-auth-")) + bin = path.join(dir, "bin") + await installFakeOmp(bin) + await writeFile(path.join(bin, "credentials.json"), EXPORT) + spawns = 0 +}) + +afterEach(async () => { + await server?.stop(true) + server = undefined + resetOmpCheck() + await rm(dir, { recursive: true, force: true }) +}) + +const url = (route: string) => `http://127.0.0.1:${server?.port}${route}` +const post = () => fetch(url(OMP_ROUTES.authImport), { method: "POST" }) +const calls = async () => + (await readFile(path.join(bin, "calls.jsonl"), "utf8").catch(() => "")) + .trim() + .split("\n") + .filter(Boolean) + .map((line) => { + const call: Call = JSON.parse(line) + return call + }) +const authCalls = async () => (await calls()).filter((call) => call.argv.includes("auth")) +const flag = (name: string, content = "on") => writeFile(path.join(bin, name), content) +const leaks = (text: string) => SECRETS.filter((secret) => text.includes(secret)) + +function errorOf(body: unknown): string { + if (isRecord(body) && typeof body.error === "string") return body.error + throw new Error("the response has no error sentence") +} + +async function alive(pid: number): Promise { + try { + const stat = await readFile(`/proc/${pid}/stat`, "utf8") + // A killed child nobody has reaped yet is a zombie: gone for our purposes. + return stat.slice(stat.lastIndexOf(")") + 2)[0] !== "Z" + } catch { + try { + process.kill(pid, 0) + return true + } catch { + return false + } + } +} + +describe("POST authImport", () => { + test("copies the default profile's logins into the configured profile, byte for byte", async () => { + start() + await fetch(url(OMP_ROUTES.status)) + expect(spawns).toBe(1) + + const response = await post() + expect(response.status).toBe(200) + const text = await response.text() + const body: OmpAuthImportResult = JSON.parse(text) + expect(body.imported).toBe(2) + expect(body.status).toMatchObject({ available: true, profile: "openflow", providers: { authenticated: 1, total: 1 }, problems: [] }) + // The provider answer was dropped, so the fresh status asked omp again. + expect(spawns).toBe(2) + + const copied = await readFile(path.join(bin, "profiles", "openflow", "credentials.json")) + expect(Buffer.compare(copied, Buffer.from(EXPORT, "utf8"))).toBe(0) + + const auth = await authCalls() + expect(auth.map((call) => call.argv)).toEqual([["auth", "export"], ["--profile", "openflow", "auth", "import", "-"]]) + expect(auth[0]?.argv).not.toContain("--profile") + expect(auth.flatMap((call) => call.leaked)).toEqual([]) + expect(leaks(text)).toEqual([]) + expect(leaks(JSON.stringify(await calls()))).toEqual([]) + }) + + test("only POST is accepted", async () => { + start() + const response = await fetch(url(OMP_ROUTES.authImport)) + expect(response.status).toBe(405) + expect(response.headers.get("allow")).toBe("POST") + }) + + test("the default profile has nothing to copy: 400 and omp is not asked for logins", async () => { + start() + const put = await fetch(url(OMP_ROUTES.settings), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ profile: "" }), + }) + expect(put.status).toBe(200) + const response = await post() + expect(response.status).toBe(400) + expect(errorOf(await response.json())).toBe("The default profile is already in use, so there is nothing to copy.") + expect(await authCalls()).toEqual([]) + }) + + test("a failing export is a 400 and nothing is imported", async () => { + start() + await flag("fail-export") + const response = await post() + expect(response.status).toBe(400) + expect(errorOf(await response.json())).toBe("Your default omp profile has no logins to copy. Run omp and use /login there first.") + expect((await authCalls()).map((call) => call.argv)).toEqual([["auth", "export"]]) + }) + + test("an export with zero credentials is the same 400", async () => { + start() + await writeFile(path.join(bin, "credentials.json"), JSON.stringify({ version: 1, credentials: [] })) + const response = await post() + expect(response.status).toBe(400) + expect(errorOf(await response.json())).toContain("no logins to copy") + expect(await authCalls()).toHaveLength(1) + }) + + test("an export in a form OpenFlow does not know is a 502 that does not quote it", async () => { + start() + await writeFile(path.join(bin, "credentials.json"), `not json ${SECRETS[0]}`) + const response = await post() + expect(response.status).toBe(502) + const text = await response.text() + expect(text).toContain("does not understand") + expect(leaks(text)).toEqual([]) + expect(await authCalls()).toHaveLength(1) + }) + + test("a failing import is a 502 whose message is capped and redacted", async () => { + start() + await flag("fail-import", `credential rejected ${SECRETS[0]} ${"because ".repeat(80)}${SECRETS[1]}`) + const response = await post() + expect(response.status).toBe(502) + const text = await response.text() + const message = errorOf(JSON.parse(text)) + expect(message.startsWith("Could not copy the logins: credential rejected")).toBe(true) + expect(message.length).toBeLessThanOrEqual(300) + expect(leaks(text)).toEqual([]) + expect(leaks(JSON.stringify(await calls()))).toEqual([]) + }) + + test("a short failing import is reported whole, with secrets masked", async () => { + start() + await flag("fail-import", `bad token ${SECRETS[0]}`) + const message = errorOf(await (await post()).json()) + expect(message).toContain("bad token") + expect(message).toContain("[REDACTED") + expect(leaks(message)).toEqual([]) + }) + + test("a hung omp is killed with everything it started and answered as a timeout", async () => { + start(1500) + await flag("hang") + const started = Date.now() + const response = await post() + expect(Date.now() - started).toBeLessThan(10_000) + expect(response.status).toBe(502) + expect(errorOf(await response.json())).toBe("omp did not answer in time") + + const pids: unknown = JSON.parse(await readFile(path.join(bin, "pids.json"), "utf8")) + if (!isRecord(pids) || typeof pids.child !== "number" || typeof pids.grandchild !== "number") throw new Error("the fake omp recorded no pids") + const child = pids.child + const grandchild = pids.grandchild + // Real processes dying is the thing under test; there is no event to await for a SIGKILLed group. + const deadline = Date.now() + 3000 + while (Date.now() < deadline && ((await alive(child)) || (await alive(grandchild)))) await Bun.sleep(50) + expect(await alive(child)).toBe(false) + expect(await alive(grandchild)).toBe(false) + }) + + test("omp missing is a 503", async () => { + start() + await rm(path.join(bin, "omp")) + expect((await post()).status).toBe(503) + }) +}) diff --git a/packages/flow/lib/omp-config/auth.ts b/packages/flow/lib/omp-config/auth.ts new file mode 100644 index 0000000000..4f2c3d06e0 --- /dev/null +++ b/packages/flow/lib/omp-config/auth.ts @@ -0,0 +1,80 @@ +import type { OmpSettings } from "../../shared/omp" +import { HttpError } from "../host/respond" +import { redact, stripControl } from "../substrate/guard" +import { childEnv, RunTimeout, type Run, type RunResult } from "./config" +import { isRecord } from "./settings" + +/** + * Copies the logins of the user's default omp profile into OpenFlow's own + * profile: `omp auth export` (default profile, no `--profile`) into memory, then + * `omp --profile

      auth import -` with that text on stdin. omp 18.12.2 exports + * `{ "version": , "credentials": [{ "provider": ..., "credential": ... }] }`. + * + * The export is credentials in clear text. It lives in one local string for the + * length of the two calls: never in argv (every local user can read it), a file, + * a log, a response or an error message. Failure text comes from the import's + * stderr only, redacted and capped. + */ + +const MESSAGE_CHARS = 300 + +export type Auth = { + /** Resolves with the number of credentials copied; throws an `HttpError` otherwise. */ + copy(settings: OmpSettings, binary: string): Promise +} + +/** Stored credentials in an `omp auth export`, or undefined when the text is not that shape. */ +function countCredentials(text: string): number | undefined { + try { + const exported: unknown = JSON.parse(text) + if (isRecord(exported) && Array.isArray(exported.credentials)) return exported.credentials.length + } catch { + // Not JSON: reported below as an export this version does not understand. + } + return undefined +} + +/** What the user may read of a failure's stderr: control characters, secret shapes and long opaque strings removed. */ +function importFailure(stderr: string): string { + const prefix = "Could not copy the logins: " + const detail = redact(stripControl(stderr)) + .replace(/[A-Za-z0-9+/_=-]{32,}/g, "[REDACTED]") + .replace(/\s+/g, " ") + .trim() + .replace(/^Error:\s*/, "") + return `${prefix}${detail || "omp failed"}`.slice(0, MESSAGE_CHARS) +} + +export function createAuth(deps: { run: Run; env: NodeJS.ProcessEnv }): Auth { + async function exec(binary: string, args: string[], input?: string): Promise { + try { + return await deps.run(binary, args, childEnv(deps.env), input) + } catch (error) { + if (error instanceof RunTimeout) throw new HttpError(502, "omp did not answer in time", "omp-timeout") + throw new HttpError(502, "omp could not be run", "omp-failed") + } + } + + return { + async copy(settings, binary) { + if (!settings.profile) + throw new HttpError(400, "The default profile is already in use, so there is nothing to copy.", "default-profile") + + const exported = await exec(binary, ["auth", "export"]) + const noLogins = new HttpError( + 400, + "Your default omp profile has no logins to copy. Run omp and use /login there first.", + "no-logins", + ) + if (exported.code !== 0) throw noLogins + const imported = countCredentials(exported.stdout) + if (imported === undefined) + throw new HttpError(502, "omp exported its logins in a form this version of OpenFlow does not understand.", "omp-failed") + if (imported === 0) throw noLogins + + const result = await exec(binary, ["--profile", settings.profile, "auth", "import", "-"], exported.stdout) + if (result.code !== 0) throw new HttpError(502, importFailure(result.stderr), "omp-failed") + return imported + }, + } +} diff --git a/packages/flow/lib/omp-config/config.ts b/packages/flow/lib/omp-config/config.ts index 57dffe6062..87b79ccbc8 100644 --- a/packages/flow/lib/omp-config/config.ts +++ b/packages/flow/lib/omp-config/config.ts @@ -1,4 +1,4 @@ -import { execFile } from "node:child_process" +import { spawn, type ChildProcess } from "node:child_process" import { stat } from "node:fs/promises" import type { OmpConfigEntry, OmpConfigType, OmpSettings, ValidationError } from "../../shared/omp" import { resolveBinary } from "../omp/binary" @@ -28,14 +28,16 @@ import { isRecord } from "./settings" const TYPES: readonly string[] = ["boolean", "number", "enum", "string", "array", "record"] const SCALAR_NAMES: readonly string[] = ["boolean", "number", "string", "array", "record"] -const RUN_TIMEOUT_MS = 30_000 +export const RUN_TIMEOUT_MS = 30_000 +const MAX_OUTPUT = 32 * 1024 * 1024 /** Anchored at the end: `compaction.reserveTokens` is a count, `auth.broker.token` is a credential. */ const SENSITIVE = /(token|secret|password|passphrase|api[-_]?key|credentials?|private[-_]?key)$/i /** The host environment omp needs to find its profile; provider keys and OpenFlow's own tokens stay out. */ const ENV_ALLOW = /^(PATH|HOME|USER|LOGNAME|SHELL|LANG|LC_[A-Z_]+|TMPDIR|XDG_[A-Z_]+|PI_[A-Z_]+|OMP_PROFILE|USERPROFILE|APPDATA|LOCALAPPDATA|SystemRoot|SYSTEMROOT)$/ export type RunResult = { code: number; stdout: string; stderr: string } -export type Run = (binary: string, args: string[], env: Record) => Promise +/** `input`, when given, is written to the child's stdin; it is never logged or kept. */ +export type Run = (binary: string, args: string[], env: Record, input?: string) => Promise export type ListingMeta = { type: OmpConfigType; group?: string; options?: string[] } @@ -49,21 +51,82 @@ export class ValidationFailure extends HttpError { } } -export const defaultRun: Run = (binary, args, env) => { - const { promise, resolve, reject } = Promise.withResolvers() - execFile( - binary, - args, - { env, timeout: RUN_TIMEOUT_MS, maxBuffer: 32 * 1024 * 1024, windowsHide: true }, - (error, stdout, stderr) => { - if (!error) return resolve({ code: 0, stdout, stderr }) - if (typeof error.code === "number") return resolve({ code: error.code, stdout, stderr }) - reject(error.killed ? new Error(`omp ${args.at(-1) ?? ""} timed out`) : error) - }, - ) - return promise +/** The child did not finish within the deadline; its process group was killed. */ +export class RunTimeout extends Error { + constructor(command: string) { + super(`omp ${command} timed out`) + } +} + +function killTree(child: ChildProcess) { + if (child.pid !== undefined && process.platform !== "win32") { + try { + process.kill(-child.pid, "SIGKILL") + return + } catch { + // The group is already gone; fall back to the child itself. + } + } + child.kill("SIGKILL") } +/** + * Runs a command to completion. The child leads its own process group on POSIX so a + * timeout kills grandchildren too, and every pipe is closed so nothing keeps the + * event loop alive. `input` goes to stdin and is not kept; without it stdin is closed. + */ +export function createRun(timeoutMs: number): Run { + return (binary, args, env, input) => { + const { promise, resolve, reject } = Promise.withResolvers() + const child = spawn(binary, args, { + env, + detached: process.platform !== "win32", + windowsHide: true, + stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"], + }) + let stdout = "" + let stderr = "" + let settled = false + + function settle(finish: () => void) { + if (settled) return + settled = true + clearTimeout(timer) + finish() + } + function abort(error: Error) { + settle(() => reject(error)) + killTree(child) + child.stdin?.destroy() + child.stdout?.destroy() + child.stderr?.destroy() + } + const timer = setTimeout(() => abort(new RunTimeout(args.at(-1) ?? "")), timeoutMs) + + child.stdout?.setEncoding("utf8") + child.stderr?.setEncoding("utf8") + child.stdout?.on("data", (chunk: string) => { + stdout += chunk + if (stdout.length > MAX_OUTPUT) abort(new Error("omp printed too much output")) + }) + child.stderr?.on("data", (chunk: string) => { + stderr += chunk + if (stderr.length > MAX_OUTPUT) abort(new Error("omp printed too much output")) + }) + child.on("error", (error) => abort(error)) + child.on("close", (code, signal) => + settle(() => (code === null ? reject(new Error(`omp stopped on ${signal ?? "a signal"}`)) : resolve({ code, stdout, stderr }))), + ) + if (input !== undefined) { + child.stdin?.on("error", () => undefined) + child.stdin?.end(input) + } + return promise + } +} + +export const defaultRun: Run = createRun(RUN_TIMEOUT_MS) + export function childEnv(env: NodeJS.ProcessEnv): Record { const result: Record = { NO_COLOR: "1", TERM: "dumb" } for (const [name, value] of Object.entries(env)) { diff --git a/packages/flow/lib/omp-config/fake-omp.ts b/packages/flow/lib/omp-config/fake-omp.ts index f0bfdacc37..22c91edf8b 100644 --- a/packages/flow/lib/omp-config/fake-omp.ts +++ b/packages/flow/lib/omp-config/fake-omp.ts @@ -10,7 +10,8 @@ import path from "node:path" */ const SCRIPT = String.raw` -import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs" +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { spawn } from "node:child_process" import path from "node:path" const dir = path.dirname(process.argv[1]) @@ -48,10 +49,37 @@ if (rest[0] === "--version") { console.log("omp/" + VERSION) process.exit(0) } -if (rest[0] !== "config") fail("unsupported: " + rest.join(" ")) +if (rest[0] === "auth") { + const readFlag = (name) => (existsSync(path.join(dir, name)) ? readFileSync(path.join(dir, name), "utf8") : undefined) + if (readFlag("hang") !== undefined) { + const grandchild = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" }) + writeFileSync(path.join(dir, "pids.json"), JSON.stringify({ child: process.pid, grandchild: grandchild.pid })) + setInterval(() => {}, 1000) + } else if (rest[1] === "export") { + const store = path.join(dir, "credentials.json") + if (readFlag("fail-export") !== undefined || !existsSync(store)) fail("No stored credentials") + process.stdout.write(readFileSync(store)) + process.exit(0) + } else if (rest[1] === "import" && rest[2] === "-") { + const input = readFileSync(0) + const failure = readFlag("fail-import") + if (failure !== undefined) fail(failure) + try { + JSON.parse(input.toString("utf8")) + } catch { + fail("not a credentials file") + } + const target = path.join(dir, "profiles", profile || "default") + mkdirSync(target, { recursive: true }) + writeFileSync(path.join(target, "credentials.json"), input) + process.exit(0) + } else fail("unsupported auth call") +} else if (rest[0] !== "config") fail("unsupported: " + rest.join(" ")) const [, action, key, ...values] = rest -if (action === "path") console.log(path.join(dir, "profiles", profile || "default", "agent")) +if (rest[0] === "auth") { + // Only the hanging variant gets here; the event loop stays alive on purpose. +} else if (action === "path") console.log(path.join(dir, "profiles", profile || "default", "agent")) else if (action === "list" && key === "--json") { const out = {} for (const row of SCHEMA) { diff --git a/packages/flow/lib/omp-config/index.ts b/packages/flow/lib/omp-config/index.ts index c2dfa186a5..70720c7a3e 100644 --- a/packages/flow/lib/omp-config/index.ts +++ b/packages/flow/lib/omp-config/index.ts @@ -1,7 +1,8 @@ import type { OmpSettings, OmpStatus } from "../../shared/omp" import type { HostHandler } from "../host/respond" import type { BuildLaunch, SpawnOmp } from "../omp/rpc-types" -import { createConfig, defaultRun, type Run } from "./config" +import { createAuth } from "./auth" +import { createConfig, createRun, RUN_TIMEOUT_MS, type Run } from "./config" import { createProviders } from "./providers" import { createRoutes } from "./routes" import { createSettingsStore } from "./settings" @@ -13,8 +14,10 @@ export type OmpConfigDeps = { env?: NodeJS.ProcessEnv spawn: SpawnOmp launch: BuildLaunch - /** Test seam: runs the omp CLI to completion. The default is `execFile` with a timeout. */ - run?: (binary: string, args: string[], env: Record) => Promise<{ code: number; stdout: string; stderr: string }> + /** Test seam: runs the omp CLI to completion. The default spawns it with a deadline. */ + run?: Run + /** Deadline of the default runner, in milliseconds. Defaults to 30 s. */ + runTimeoutMs?: number } export type OmpConfigModule = { @@ -28,11 +31,12 @@ export type OmpConfigModule = { export function createOmpConfig(deps: OmpConfigDeps): OmpConfigModule { const env = deps.env ?? process.env - const run: Run = deps.run ?? defaultRun + const run: Run = deps.run ?? createRun(deps.runTimeoutMs ?? RUN_TIMEOUT_MS) const store = createSettingsStore({ stateDir: deps.stateDir, env }) const providers = createProviders({ env, spawn: deps.spawn, launch: deps.launch }) const checker = createStatusChecker({ run, env, providers }) const config = createConfig({ run, env }) + const auth = createAuth({ run, env }) // A different binary or profile has different credentials and a different config directory. store.changed(() => { @@ -43,7 +47,7 @@ export function createOmpConfig(deps: OmpConfigDeps): OmpConfigModule { const settings = async () => (await store.load()).settings return { - handle: createRoutes({ store, checker, config, providers, env }), + handle: createRoutes({ store, checker, config, providers, auth, env }), settings, status: async () => checker.status(await settings()), changed: store.changed, diff --git a/packages/flow/lib/omp-config/routes.ts b/packages/flow/lib/omp-config/routes.ts index 630e11d820..f1f52b7f6d 100644 --- a/packages/flow/lib/omp-config/routes.ts +++ b/packages/flow/lib/omp-config/routes.ts @@ -1,6 +1,7 @@ -import { OMP_ROUTES, type OmpStatusView } from "../../shared/omp" +import { OMP_ROUTES, type OmpAuthImportResult, type OmpStatusView } from "../../shared/omp" import { fail, guarded, HttpError, json, readJson, type HostHandler } from "../host/respond" -import { resolveBinary } from "../omp/binary" +import { resolveBinary, resetOmpCheck } from "../omp/binary" +import type { Auth } from "./auth" import { ValidationFailure, firstLine, type Config } from "./config" import type { Providers } from "./providers" import { isRecord, type SettingsStore } from "./settings" @@ -11,6 +12,7 @@ export type RouteParts = { checker: StatusChecker config: Config providers: Providers + auth: Auth env: NodeJS.ProcessEnv } @@ -76,6 +78,19 @@ export function createRoutes(parts: RouteParts): HostHandler { return json(200, await parts.store.reset()) } + if (pathname === OMP_ROUTES.authImport) { + if (request.method !== "POST") return methodNotAllowed("POST") + const loaded = await parts.store.load() + const binary = resolveBinary(loaded.settings, parts.env) + if (!binary) throw new HttpError(503, "omp was not found; set its path in Omp settings", "omp-unavailable") + const imported = await parts.auth.copy(loaded.settings, binary) + // The new logins change what the check and the provider list answer. + resetOmpCheck() + parts.providers.reset() + const status = await parts.checker.status(loaded.settings) + return json(200, { imported, status: { ...status, warnings: loaded.warnings } } satisfies OmpAuthImportResult) + } + if (pathname === OMP_ROUTES.providers) { if (request.method !== "GET") return methodNotAllowed("GET") const { settings } = await parts.store.load() diff --git a/packages/flow/lib/omp-config/status.test.ts b/packages/flow/lib/omp-config/status.test.ts index 5049b04331..48bd5b97fd 100644 --- a/packages/flow/lib/omp-config/status.test.ts +++ b/packages/flow/lib/omp-config/status.test.ts @@ -86,6 +86,7 @@ describe("createStatusChecker", () => { expect(status.problems).toHaveLength(1) expect(status.problems[0]).toMatchObject({ code: "no-credentials" }) expect(status.problems[0]?.message).toContain("openflow") + expect(status.problems[0]?.fix).toContain("Copy logins") expect(status.problems[0]?.fix).toContain("/login") }) diff --git a/packages/flow/lib/omp-config/status.ts b/packages/flow/lib/omp-config/status.ts index 4b5fc5e2c2..3733569e77 100644 --- a/packages/flow/lib/omp-config/status.ts +++ b/packages/flow/lib/omp-config/status.ts @@ -49,7 +49,7 @@ export function createStatusChecker(deps: StatusDeps): StatusChecker { problems.push({ code: "no-credentials", message: `The ${profile} profile has no credentials.`, - fix: "Open the terminal and run /login.", + fix: "Use the Copy logins button in Omp settings, or open the terminal and run /login.", }) } else if (!authenticated.some((row) => row.available)) { problems.push({ diff --git a/packages/flow/shared/omp.ts b/packages/flow/shared/omp.ts index 3eee64b4ac..ceaf7c8de5 100644 --- a/packages/flow/shared/omp.ts +++ b/packages/flow/shared/omp.ts @@ -137,6 +137,8 @@ export const OMP_ROUTES = { /** GET lists entries; PUT `/` sets `{ value }`; POST `//reset` resets. */ config: "/flow/api/omp/config", providers: "/flow/api/omp/providers", + /** POST, no body: copies the default profile's logins into the configured profile. */ + authImport: "/flow/api/omp/auth/import", } as const /** Where a value actually comes from. Names of environment variables only, never their values. */ @@ -152,3 +154,6 @@ export type OmpSettingsView = { /** `OmpStatus` as the route sends it: settings-file warnings ride along so the dialog can show them. */ export type OmpStatusView = OmpStatus & { warnings: string[] } + +/** `POST OMP_ROUTES.authImport`: how many stored logins were copied, and the status afterwards. */ +export type OmpAuthImportResult = { imported: number; status: OmpStatusView } diff --git a/packages/flow/src/server/omp-config.test.ts b/packages/flow/src/server/omp-config.test.ts index c402cfcfe3..e6cd21d48f 100644 --- a/packages/flow/src/server/omp-config.test.ts +++ b/packages/flow/src/server/omp-config.test.ts @@ -103,6 +103,23 @@ describe("omp client", () => { expect(result.ok && result.value).toEqual([{ id: "a", name: "A", available: true, authenticated: false }]) }) + test("importAuth POSTs without a body and returns the count and fresh status", async () => { + const status = { available: true, problems: [], warnings: [], profile: "openflow", minVersion: "18.12.2", checkedAt: 1 } + reply = () => Response.json({ imported: 2, status }) + const result = await client.importAuth() + expect(result).toEqual({ ok: true, value: { imported: 2, status } }) + expect(seen).toEqual([{ method: "POST", path: OMP_ROUTES.authImport, body: undefined }]) + }) + + test("importAuth carries the host's sentence when it refuses", async () => { + reply = () => Response.json({ error: "Your default omp profile has no logins to copy. Run omp and use /login there first." }, { status: 400 }) + expect(await client.importAuth()).toEqual({ + ok: false, + status: 400, + errors: [{ path: "", message: "Your default omp profile has no logins to copy. Run omp and use /login there first." }], + }) + }) + test("a host that is not listening is status 0, never a throw", async () => { const closed = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("") }) const base = `http://127.0.0.1:${closed.port}` diff --git a/packages/flow/src/server/omp-config.ts b/packages/flow/src/server/omp-config.ts index 9c9704e98f..03ad1f80b1 100644 --- a/packages/flow/src/server/omp-config.ts +++ b/packages/flow/src/server/omp-config.ts @@ -1,5 +1,6 @@ import { OMP_ROUTES, + type OmpAuthImportResult, type OmpConfigEntry, type OmpProviderRow, type OmpSettingsPatch, @@ -33,6 +34,8 @@ export type OmpClient = { setConfig(key: string, value: unknown): Promise> resetConfig(key: string): Promise> providers(): Promise> + /** Copies the default omp profile's logins into the OpenFlow profile; answers how many and the fresh status. */ + importAuth(): Promise> } function readErrors(body: unknown, status: number): ValidationError[] { @@ -90,6 +93,7 @@ export function createOmpClient(options: OmpClientOptions = {}): OmpClient { request<{ entry: OmpConfigEntry }, OmpConfigEntry>(`${key(name)}/reset`, "POST", undefined, (payload) => payload.entry), providers: () => request<{ providers: OmpProviderRow[] }, OmpProviderRow[]>(OMP_ROUTES.providers, "GET", undefined, (payload) => payload.providers), + importAuth: () => request(OMP_ROUTES.authImport, "POST"), } } diff --git a/packages/flow/src/ui/omp-settings-model.test.ts b/packages/flow/src/ui/omp-settings-model.test.ts index edbb1a4d4a..fe3878167e 100644 --- a/packages/flow/src/ui/omp-settings-model.test.ts +++ b/packages/flow/src/ui/omp-settings-model.test.ts @@ -8,11 +8,14 @@ import { } from "../../shared/omp" import type { SwarmPreflight } from "../../shared/swarm" import { + COPY_LOGINS_WARNING, FIELDS, SECTIONS, TABS, UNGROUPED, buildPatch, + canCopyLogins, + copyLoginsOutcome, describedBy, dirtyPaths, displayValue, @@ -304,6 +307,33 @@ describe("status", () => { expect(problemActions({ code: "spawn", message: "" })).toEqual(["settings", "terminal"]) expect(problemActions({ code: "profile", message: "" })).toEqual(["settings", "terminal"]) }) + + test("the copy-logins button is offered for a missing-credentials problem in a named profile only", () => { + const missing = { code: "no-credentials", message: "m" } as const + expect(canCopyLogins(healthy, missing)).toBe(true) + expect(canCopyLogins({ ...healthy, profile: "" }, missing)).toBe(false) + expect(canCopyLogins(healthy, { code: "no-models", message: "m" })).toBe(false) + expect(canCopyLogins(healthy, { code: "profile", message: "m" })).toBe(false) + }) + + test("the copy outcome counts logins, or carries the host's sentence", () => { + const value = (imported: number) => ({ ok: true as const, value: { imported, status: healthy } }) + expect(copyLoginsOutcome(value(3))).toEqual({ ok: true, text: "Copied 3 logins." }) + expect(copyLoginsOutcome(value(1))).toEqual({ ok: true, text: "Copied 1 login." }) + expect( + copyLoginsOutcome({ + ok: false, + status: 400, + errors: [{ path: "", message: "Your default omp profile has no logins to copy. Run omp and use /login there first." }], + }), + ).toEqual({ ok: false, text: "Your default omp profile has no logins to copy. Run omp and use /login there first." }) + expect(copyLoginsOutcome({ ok: false, status: 0, errors: [{ path: "", message: "The OpenFlow host did not answer." }] }).ok).toBe(false) + }) + + test("the confirm sentence says where the keys go", () => { + expect(COPY_LOGINS_WARNING).toContain("API keys and logins") + expect(COPY_LOGINS_WARNING).toContain("OpenFlow profile on this machine") + }) }) describe("swarm view", () => { diff --git a/packages/flow/src/ui/omp-settings-model.ts b/packages/flow/src/ui/omp-settings-model.ts index f7de719f2e..63df57ccb0 100644 --- a/packages/flow/src/ui/omp-settings-model.ts +++ b/packages/flow/src/ui/omp-settings-model.ts @@ -1,5 +1,6 @@ import { DEFAULT_OMP_SETTINGS, + type OmpAuthImportResult, type OmpConfigEntry, type OmpProblem, type OmpSettings, @@ -357,6 +358,28 @@ export function problemActions(problem: OmpProblem): ProblemAction[] { } } +// ------------------------------------------------------------ copy logins + +export type CopyStep = "idle" | "confirm" | "running" + +export const COPY_LOGINS_LABEL = "Copy logins from your default profile" + +export const COPY_LOGINS_WARNING = + "This copies the API keys and logins stored in your default omp profile into the OpenFlow profile on this machine. Nothing is removed from the default profile." + +/** Only a named profile can receive logins, and only a profile with none is missing them. */ +export function canCopyLogins(status: OmpStatusView, problem: OmpProblem): boolean { + return problem.code === "no-credentials" && status.profile !== "" +} + +export type CopyOutcome = { ok: boolean; text: string } + +/** The sentence for the live region: the count on success, the host's own sentence otherwise. */ +export function copyLoginsOutcome(result: OmpResult): CopyOutcome { + if (!result.ok) return { ok: false, text: result.errors.map((error) => error.message).join(" ") } + return { ok: true, text: `Copied ${result.value.imported} ${result.value.imported === 1 ? "login" : "logins"}.` } +} + export type StatusRow = { label: string; value: string; ok: boolean } export function statusRows(status: OmpStatusView): StatusRow[] { diff --git a/packages/flow/src/ui/omp-settings.css b/packages/flow/src/ui/omp-settings.css index e8133ed252..177f07d345 100644 --- a/packages/flow/src/ui/omp-settings.css +++ b/packages/flow/src/ui/omp-settings.css @@ -66,6 +66,10 @@ color: var(--oc-text-muted); } +.omp-note[data-failed="true"] { + color: var(--oc-danger); +} + .omp-hint, .omp-count { font-size: 12px; diff --git a/packages/flow/src/ui/omp-settings.tsx b/packages/flow/src/ui/omp-settings.tsx index a2f65ab119..9ab8d02e08 100644 --- a/packages/flow/src/ui/omp-settings.tsx +++ b/packages/flow/src/ui/omp-settings.tsx @@ -8,10 +8,14 @@ import { IconClose } from "./icons" import { MemoryPanel } from "./memory-panel" import { memoryLineView } from "./memory-panel-model" import { + COPY_LOGINS_LABEL, + COPY_LOGINS_WARNING, FIELDS, SECTIONS, TABS, buildPatch, + canCopyLogins, + copyLoginsOutcome, describedBy, dirtyPaths, displayValue, @@ -37,6 +41,8 @@ import { tabTarget, toDraft, trapTarget, + type CopyOutcome, + type CopyStep, type Draft, type DraftValue, type Field, @@ -166,6 +172,11 @@ function Dialog(props: Omit) { const memoryLine = createMemo(() => memoryLineView(memoryStatus())) let extrasRequest = 0 let closed = false + const [copyStep, setCopyStep] = createSignal("idle") + const [copyResult, setCopyResult] = createSignal() + let copyButton: HTMLButtonElement | undefined + let copyCancel: HTMLButtonElement | undefined + let checkButton: HTMLButtonElement | undefined const [settings, loadSettings, setSettings] = createRemote(() => omp.settings()) const [config, loadConfig, setConfig] = createRemote(() => omp.config()) @@ -239,6 +250,34 @@ function Dialog(props: Omit) { props.onClose() } + // --------------------------------------------------------- copy logins + + function askCopy() { + setCopyStep("confirm") + queueMicrotask(() => copyCancel?.focus()) + } + + function cancelCopy() { + setCopyStep("idle") + queueMicrotask(() => copyButton?.focus()) + } + + async function copyLogins() { + setCopyStep("running") + setCopyResult(undefined) + const outcome = copyLoginsOutcome(await omp.importAuth()) + if (closed) return + setCopyStep("idle") + setCopyResult(outcome) + setAnnouncement(outcome.text) + if (!outcome.ok) { + queueMicrotask(() => copyButton?.focus()) + return + } + await loadStatus() + if (!closed) checkButton?.focus() + } + function onKeyDown(event: KeyboardEvent) { if (event.key === "Escape") { event.preventDefault() @@ -399,6 +438,13 @@ function Dialog(props: Omit) { return current.message })()}

      + + {(result) => ( +

      + {result().text} +

      + )} +
      void loadStatus()}> {(value) => ( <> @@ -422,6 +468,42 @@ function Dialog(props: Omit) {

      {problem.fix}

      + + + +
    + } + > +
    { + if (event.key !== "Escape") return + // The dialog's Escape closes the whole dialog; here it only backs out of the confirm. + event.preventDefault() + event.stopPropagation() + if (copyStep() === "confirm") cancelCopy() + }} + > + Copy your logins into the OpenFlow profile? +

    {COPY_LOGINS_WARNING}

    +
    + + +
    +
    + +
    {(action) => ( @@ -441,7 +523,7 @@ function Dialog(props: Omit) {
    - - + + +
    )} diff --git a/packages/flow/src/ui/walkthrough.test.ts b/packages/flow/src/ui/walkthrough.test.ts index 151ac3560d..f336535d01 100644 --- a/packages/flow/src/ui/walkthrough.test.ts +++ b/packages/flow/src/ui/walkthrough.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { walkthroughState } from "./walkthrough" -const base = { hostReachable: true, ompReady: true, modelCount: 0, nodes: 0, edges: 0, dismissed: false } +const base = { hostReachable: true, ompReady: true, modelCount: 0, modelsLoaded: true, nodes: 0, edges: 0, dismissed: false } describe("walkthroughState", () => { test("a fresh empty canvas is visible with nothing done", () => { @@ -21,6 +21,24 @@ describe("walkthroughState", () => { expect(walkthroughState(base).steps.models).toBe(false) }) + test("while the catalog is in flight the models step is loading: neither done nor an empty-catalog answer", () => { + const loading = walkthroughState({ ...base, modelsLoaded: false }) + expect(loading.steps.models).toBe(false) + expect(loading.loadingModels).toBe(true) + expect(walkthroughState(base).loadingModels).toBe(false) + }) + + test("listed models end the loading state even before the read settles", () => { + const result = walkthroughState({ ...base, modelCount: 3, modelsLoaded: false }) + expect(result.steps.models).toBe(true) + expect(result.loadingModels).toBe(false) + }) + + test("nothing is loading when the host is down or omp is not ready: the first step says why", () => { + expect(walkthroughState({ ...base, modelsLoaded: false, hostReachable: false }).loadingModels).toBe(false) + expect(walkthroughState({ ...base, modelsLoaded: false, ompReady: false }).loadingModels).toBe(false) + }) + test("a node hides the overlay and ticks the node step", () => { const result = walkthroughState({ ...base, nodes: 1 }) expect(result.visible).toBe(false) diff --git a/packages/flow/src/ui/walkthrough.tsx b/packages/flow/src/ui/walkthrough.tsx index ae3026f66f..afa5acf329 100644 --- a/packages/flow/src/ui/walkthrough.tsx +++ b/packages/flow/src/ui/walkthrough.tsx @@ -22,6 +22,8 @@ export function walkthroughState(input: { hostReachable: boolean ompReady: boolean modelCount: number + /** True once the first catalog read has settled (listed models or not); false while it is in flight. */ + modelsLoaded: boolean nodes: number edges: number dismissed: boolean @@ -38,6 +40,8 @@ export function walkthroughState(input: { // A single node needs no edge to be "connected"; a bigger graph does. connect: input.nodes > 0 && (input.edges > 0 || input.nodes === 1), }, + // The catalog takes seconds to arrive; until it does, "no models" is not an answer yet. + loadingModels: input.hostReachable && input.ompReady && !input.modelsLoaded && input.modelCount === 0, } } @@ -53,6 +57,7 @@ export function Walkthrough(props: { ompReady: boolean ompMessage?: string modelCount: number + modelsLoaded: boolean onOpenOmp: () => void onOpenTerminal: () => void }) { @@ -62,6 +67,7 @@ export function Walkthrough(props: { hostReachable: state.hostReachable, ompReady: props.ompReady, modelCount: props.modelCount, + modelsLoaded: props.modelsLoaded, nodes: state.pipeline.nodes.length, edges: state.pipeline.edges.length, dismissed: dismissed(), @@ -129,7 +135,11 @@ export function Walkthrough(props: { Have a model to run Omp lists no models yet — log in to a provider from the terminal} + fallback={ + + {view().loadingModels ? "Loading models…" : "Omp lists no models yet — log in to a provider from the terminal"} + + } > {props.modelCount} {props.modelCount === 1 ? "model" : "models"} available From f5a41a436d42db552424e53fb6a426d6a0870c24 Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:22:14 +0000 Subject: [PATCH 12/49] chore(flow): drop stale opencode wording, templates and generated config Shipped templates lose their dead model pins, the generated opencode json files go, and comments, messages and test fixtures name the Omp engine. Refs SPE-16068 --- .openflow/generated/diamond.opencode.json | 61 ------- .../generated/feature-build.opencode.json | 44 ----- .../orchestrated-build.opencode.json | 54 ------ .openflow/pipelines/diamond.json | 12 +- .openflow/pipelines/feature-build.json | 9 +- packages/flow/lib/guard.ts | 14 +- packages/flow/lib/store.test.ts | 4 +- packages/flow/shared/engine.ts | 2 +- packages/flow/src/graph/default-model.test.ts | 20 +-- packages/flow/src/graph/prompt.test.ts | 10 +- packages/flow/src/graph/templates.test.ts | 8 +- packages/flow/src/graph/types.test.ts | 2 +- packages/flow/src/graph/types.ts | 6 +- packages/flow/src/graph/validate.test.ts | 166 +++++++++--------- packages/flow/src/server/engine.test.ts | 38 ++-- packages/flow/src/state.test.ts | 14 +- packages/flow/src/ui/icons.tsx | 4 +- 17 files changed, 151 insertions(+), 317 deletions(-) delete mode 100644 .openflow/generated/diamond.opencode.json delete mode 100644 .openflow/generated/feature-build.opencode.json delete mode 100644 .openflow/generated/orchestrated-build.opencode.json diff --git a/.openflow/generated/diamond.opencode.json b/.openflow/generated/diamond.opencode.json deleted file mode 100644 index 5df42375e0..0000000000 --- a/.openflow/generated/diamond.opencode.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "agent": { - "diamond-planner": { - "mode": "primary", - "description": "OpenFlow node nmsqpvewx1 (planner) of pipeline diamond", - "model": "opencode/nemotron-3.5-lightning-free", - "prompt": "You are the planner. Answer in ONE sentence. Never call any tool. Never read files. Answer only from the text given.", - "tools": { - "read": true, - "grep": true, - "glob": true, - "write": false, - "edit": false, - "bash": false - } - }, - "diamond-worker-a": { - "mode": "primary", - "description": "OpenFlow node nmsqpvewy2 (worker-a) of pipeline diamond", - "model": "opencode/nemotron-3.5-lightning-free", - "prompt": "You are the worker-a. Answer in ONE sentence. Never call any tool. Never read files. Answer only from the text given.", - "tools": { - "read": true, - "grep": true, - "glob": true, - "write": true, - "edit": true, - "bash": true - } - }, - "diamond-worker-b": { - "mode": "primary", - "description": "OpenFlow node nmsqpvewy3 (worker-b) of pipeline diamond", - "model": "opencode/nemotron-3.5-lightning-free", - "prompt": "You are the worker-b. Answer in ONE sentence. Never call any tool. Never read files. Answer only from the text given.", - "tools": { - "read": true, - "grep": true, - "glob": true, - "write": true, - "edit": true, - "bash": true - } - }, - "diamond-reviewer": { - "mode": "primary", - "description": "OpenFlow node nmsqpvewy4 (reviewer) of pipeline diamond", - "model": "opencode/nemotron-3.5-lightning-free", - "prompt": "You are the reviewer. Answer in ONE sentence. Never call any tool. Never read files. Answer only from the text given.", - "tools": { - "read": true, - "grep": true, - "glob": true, - "write": false, - "edit": false, - "bash": false - } - } - } -} diff --git a/.openflow/generated/feature-build.opencode.json b/.openflow/generated/feature-build.opencode.json deleted file mode 100644 index 8434045aac..0000000000 --- a/.openflow/generated/feature-build.opencode.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "agent": { - "feature-build-planner": { - "mode": "primary", - "description": "OpenFlow node nmsqpnwo61 (planner) of pipeline feature-build", - "model": "opencode/nemotron-3.5-lightning-free", - "prompt": "You are the planner. Break the task into concrete, ordered steps. Name the files and interfaces involved. Do not write implementation code. End with a numbered plan. Answer in at most 2 short sentences. Do not use tools.", - "tools": { - "read": true, - "grep": true, - "glob": true, - "edit": false, - "bash": false - } - }, - "feature-build-architect": { - "mode": "primary", - "description": "OpenFlow node nmsqpnwo72 (architect) of pipeline feature-build", - "model": "opencode/nemotron-3.5-lightning-free", - "prompt": "You are the architect. Given the plan, decide module boundaries, data shapes, and the public interfaces. Output signatures and file layout. No implementation. Answer in at most 2 short sentences. Do not use tools.", - "tools": { - "read": true, - "grep": true, - "glob": true, - "edit": false, - "bash": false - } - }, - "feature-build-coder": { - "mode": "primary", - "description": "OpenFlow node nmsqpnwo83 (coder) of pipeline feature-build", - "model": "opencode/nemotron-3.5-lightning-free", - "prompt": "You are the coder. Implement exactly what the upstream plan and architecture specify. Keep diffs tight. Answer in at most 2 short sentences. Do not use tools.", - "tools": { - "read": true, - "grep": true, - "glob": true, - "edit": true, - "bash": true - } - } - } -} diff --git a/.openflow/generated/orchestrated-build.opencode.json b/.openflow/generated/orchestrated-build.opencode.json deleted file mode 100644 index baddd721dd..0000000000 --- a/.openflow/generated/orchestrated-build.opencode.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "agent": { - "orchestrated-build-orchestrator-nmtnsikw10": { - "mode": "primary", - "description": "OpenFlow node nmtnsikw10 (orchestrator) of pipeline orchestrated build", - "prompt": "You are the orchestrator. Decide what has to happen, split it across the cards below you by what each is for, and give each one a task it can finish alone. Read what they return, dispatch again where the answer is not good enough, and write the final answer yourself. Do a card's work yourself only when no card below you can do it.", - "permission": { - "read": "allow", - "grep": "allow", - "glob": "allow", - "edit": "deny", - "bash": "deny", - "question": "deny" - } - }, - "orchestrated-build-architect-nmtnsikw11": { - "mode": "primary", - "description": "OpenFlow node nmtnsikw11 (architect) of pipeline orchestrated build", - "prompt": "You are the architect. Given the plan, decide module boundaries, data shapes, and the public interfaces. Output signatures and file layout. No implementation.", - "permission": { - "read": "allow", - "grep": "allow", - "glob": "allow", - "edit": "deny", - "bash": "deny" - } - }, - "orchestrated-build-coder-nmtnsikw12": { - "mode": "primary", - "description": "OpenFlow node nmtnsikw12 (coder) of pipeline orchestrated build", - "prompt": "You are the coder. Implement exactly what the upstream plan and architecture specify. Keep diffs tight.", - "permission": { - "read": "allow", - "grep": "allow", - "glob": "allow", - "edit": "allow", - "bash": "allow" - } - }, - "orchestrated-build-reviewer-nmtnsikw13": { - "mode": "primary", - "description": "OpenFlow node nmtnsikw13 (reviewer) of pipeline orchestrated build", - "prompt": "You are the reviewer. Audit the upstream output for correctness bugs and missed requirements. One line per finding, most severe first. No praise.", - "permission": { - "read": "allow", - "grep": "allow", - "glob": "allow", - "edit": "deny", - "bash": "deny" - } - } - } -} diff --git a/.openflow/pipelines/diamond.json b/.openflow/pipelines/diamond.json index ec7b03bd48..3763083379 100644 --- a/.openflow/pipelines/diamond.json +++ b/.openflow/pipelines/diamond.json @@ -14,8 +14,7 @@ "write": false, "edit": false, "bash": false - }, - "model": "opencode/nemotron-3.5-lightning-free" + } }, "position": { "x": 40, @@ -34,8 +33,7 @@ "write": true, "edit": true, "bash": true - }, - "model": "opencode/nemotron-3.5-lightning-free" + } }, "position": { "x": 340, @@ -54,8 +52,7 @@ "write": true, "edit": true, "bash": true - }, - "model": "opencode/nemotron-3.5-lightning-free" + } }, "position": { "x": 640, @@ -74,8 +71,7 @@ "write": false, "edit": false, "bash": false - }, - "model": "opencode/nemotron-3.5-lightning-free" + } }, "position": { "x": 40, diff --git a/.openflow/pipelines/feature-build.json b/.openflow/pipelines/feature-build.json index 704d5ac15d..41efaf797f 100644 --- a/.openflow/pipelines/feature-build.json +++ b/.openflow/pipelines/feature-build.json @@ -14,8 +14,7 @@ "write": false, "edit": false, "bash": false - }, - "model": "opencode/nemotron-3.5-lightning-free" + } }, "position": { "x": 40, @@ -34,8 +33,7 @@ "write": false, "edit": false, "bash": false - }, - "model": "opencode/nemotron-3.5-lightning-free" + } }, "position": { "x": 340, @@ -54,8 +52,7 @@ "write": true, "edit": true, "bash": true - }, - "model": "opencode/nemotron-3.5-lightning-free" + } }, "position": { "x": 640, diff --git a/packages/flow/lib/guard.ts b/packages/flow/lib/guard.ts index 3200fae95e..df4f281d2a 100644 --- a/packages/flow/lib/guard.ts +++ b/packages/flow/lib/guard.ts @@ -1,12 +1,12 @@ /** * Who is allowed to reach the store. * - * `/flow/api` writes pipelines and run logs into the project and merges agents - * into its `opencode.json`, and nothing authenticates the caller. On loopback - * that is the user talking to their own machine. Bound to a real interface it - * is a remote file-write hole in whatever repository OpenFlow is pointed at, so - * both hosts — the vite dev server and `server.ts` — refuse unless it is asked - * for explicitly. + * `/flow/api` writes pipelines and run logs into the project, starts omp + * processes that can edit it and run commands in it, and nothing authenticates + * the caller. On loopback that is the user talking to their own machine. Bound + * to a real interface it is remote code execution in whatever repository + * OpenFlow is pointed at, so both hosts — the vite dev server and `server.ts` — + * refuse unless it is asked for explicitly. */ const LOOPBACK = new Set(["127.0.0.1", "::1", "[::1]", "localhost"]) @@ -34,7 +34,7 @@ export function remoteBindRefusal(options: { const host = options.host === true ? "every interface" : String(options.host) return ( `refusing to serve OpenFlow on ${host}: the /flow/api store writes files into ${options.project} ` + - `and merges into its opencode.json, with no authentication.\n` + + `and starts agents that can run commands there, with no authentication.\n` + `set FLOW_ALLOW_REMOTE=1 if that is genuinely what you want.` ) } diff --git a/packages/flow/lib/store.test.ts b/packages/flow/lib/store.test.ts index ab7e76fe0c..45ec1b9947 100644 --- a/packages/flow/lib/store.test.ts +++ b/packages/flow/lib/store.test.ts @@ -428,8 +428,8 @@ describe("skills", () => { test("writes nothing into the project: no config, no .openflow/skills", async () => { await call("PUT", "/flow/api/skills/summarize", { body: skill }) - expect(await fs.readdir(dir)).not.toContain("opencode.json") - expect(await fs.readdir(dir)).not.toContain(".openflow") + const own: Record = { "omp-profile": true, state: true } + expect((await fs.readdir(dir)).filter((entry) => !Object.hasOwn(own, entry))).toEqual([]) }) test("reads a skill back split into frontmatter and body", async () => { diff --git a/packages/flow/shared/engine.ts b/packages/flow/shared/engine.ts index 5d6d9e4703..cdcbec1e11 100644 --- a/packages/flow/shared/engine.ts +++ b/packages/flow/shared/engine.ts @@ -86,7 +86,7 @@ export type FlowSwarmEvent = { type: "flow.swarm.status"; data: { sessionID: str /** * Everything the host needs to launch one card, in OpenFlow's own vocabulary. - * Replaces "merge agents into opencode.json and restart the engine": a changed + * Replaces merging agents into an engine config and restarting it: a changed * card simply launches a fresh process with the new spec. */ export type CardSpec = { diff --git a/packages/flow/src/graph/default-model.test.ts b/packages/flow/src/graph/default-model.test.ts index 5804d11d09..63dbcf7491 100644 --- a/packages/flow/src/graph/default-model.test.ts +++ b/packages/flow/src/graph/default-model.test.ts @@ -9,8 +9,8 @@ beforeEach(() => { describe("default model preference", () => { test("get/set round-trips and clears", () => { expect(defaultModel()).toBeUndefined() - setDefaultModel("opencode/grok-code-free") - expect(defaultModel()).toBe("opencode/grok-code-free") + setDefaultModel("test/grok-code-free") + expect(defaultModel()).toBe("test/grok-code-free") setDefaultModel(undefined) expect(defaultModel()).toBeUndefined() }) @@ -18,26 +18,26 @@ describe("default model preference", () => { describe("nodeModel", () => { test("a preset's own model always wins, whatever the default", () => { - expect(nodeModel("anthropic/claude", new Set(), "opencode/free")).toBe("anthropic/claude") + expect(nodeModel("anthropic/claude", new Set(), "test/free")).toBe("anthropic/claude") }) test("applies the default only when it is in the available set", () => { - expect(nodeModel(undefined, new Set(["opencode/free"]), "opencode/free")).toBe("opencode/free") + expect(nodeModel(undefined, new Set(["test/free"]), "test/free")).toBe("test/free") }) test("leaves the model blank when the default is not available", () => { - expect(nodeModel(undefined, new Set(["opencode/other"]), "opencode/free")).toBeUndefined() + expect(nodeModel(undefined, new Set(["test/other"]), "test/free")).toBeUndefined() }) test("leaves the model blank when there is no default", () => { - expect(nodeModel(undefined, new Set(["opencode/free"]), undefined)).toBeUndefined() + expect(nodeModel(undefined, new Set(["test/free"]), undefined)).toBeUndefined() }) test("reads the live signals when no arguments are passed", () => { - setAvailableModels(new Set(["opencode/free"])) - setDefaultModel("opencode/free") - expect(nodeModel(undefined)).toBe("opencode/free") - setDefaultModel("opencode/locked") + setAvailableModels(new Set(["test/free"])) + setDefaultModel("test/free") + expect(nodeModel(undefined)).toBe("test/free") + setDefaultModel("test/locked") expect(nodeModel(undefined)).toBeUndefined() }) }) diff --git a/packages/flow/src/graph/prompt.test.ts b/packages/flow/src/graph/prompt.test.ts index 8221f8886d..56774d51dc 100644 --- a/packages/flow/src/graph/prompt.test.ts +++ b/packages/flow/src/graph/prompt.test.ts @@ -202,7 +202,7 @@ describe("swarm prompts", () => { function swarm(rounds = 3): Pipeline { const graph = pipeline("alpha", "beta", "verdict") graph.nodes[2].role = "synthesizer" - for (const entry of graph.nodes) entry.agent.model = "opencode/x" + for (const entry of graph.nodes) entry.agent.model = "test/x" return { ...graph, mode: "swarm", rounds } } @@ -213,8 +213,8 @@ describe("swarm prompts", () => { const text = swarmBriefing(graph, at(graph, "alpha")) expect(text).toContain("2 agent(s), 3 round(s)") - expect(text).toContain("- alpha (alpha) · opencode/x <-- YOU ARE HERE") - expect(text).toContain("- beta (beta) · opencode/x") + expect(text).toContain("- alpha (alpha) · test/x <-- YOU ARE HERE") + expect(text).toContain("- beta (beta) · test/x") expect(text).toContain("verdict: synthesizer (verdict)") // The point of the mode: a swarm that agrees because nobody argued has // burned every session in it for one opinion. @@ -303,7 +303,7 @@ describe("orchestration prompts", () => { /** A boss with two specialists, one of which has a card of its own. */ function tree(): Pipeline { const graph = pipeline("boss->coder", "boss->reviewer", "coder->helper") - for (const entry of graph.nodes) entry.agent.model = "opencode/x" + for (const entry of graph.nodes) entry.agent.model = "test/x" graph.nodes[1].agent.prompt = "You are the coder.\nKeep diffs tight." return { ...graph, mode: "orchestration", dispatches: 2 } } @@ -522,7 +522,7 @@ describe("gauntlet prompts", () => { /** An orchestrator with one builder and one critic, running as a gauntlet. */ function gauntlet(bar = "the reference build, screenshot by screenshot"): Pipeline { const graph = pipeline("boss->coder", "boss->reviewer") - for (const entry of graph.nodes) entry.agent.model = "opencode/x" + for (const entry of graph.nodes) entry.agent.model = "test/x" return { ...graph, mode: "orchestration", dispatches: 2, gauntlet: { bar } } } const at = (graph: Pipeline, id: string) => graph.nodes.find((entry) => entry.id === id)! diff --git a/packages/flow/src/graph/templates.test.ts b/packages/flow/src/graph/templates.test.ts index 03fb895c86..62d2cd1dc1 100644 --- a/packages/flow/src/graph/templates.test.ts +++ b/packages/flow/src/graph/templates.test.ts @@ -56,8 +56,8 @@ describe("the swarm template", () => { test("passes its own mode's shape rules on drop", () => { const graph = built() - for (const node of graph.nodes) node.agent.model = "opencode/x" - expect(preflight(graph, { unlockedModels: new Set(["opencode/x"]) }).blocking).toEqual([]) + for (const node of graph.nodes) node.agent.model = "test/x" + expect(preflight(graph, { unlockedModels: new Set(["test/x"]) }).blocking).toEqual([]) }) }) @@ -79,7 +79,7 @@ describe("the orchestration template", () => { test("passes its own mode's shape rules on drop", () => { const graph = built() - for (const node of graph.nodes) node.agent.model = "opencode/x" - expect(preflight(graph, { unlockedModels: new Set(["opencode/x"]) }).blocking).toEqual([]) + for (const node of graph.nodes) node.agent.model = "test/x" + expect(preflight(graph, { unlockedModels: new Set(["test/x"]) }).blocking).toEqual([]) }) }) diff --git a/packages/flow/src/graph/types.test.ts b/packages/flow/src/graph/types.test.ts index 1cc35d14e8..01d48a3a49 100644 --- a/packages/flow/src/graph/types.test.ts +++ b/packages/flow/src/graph/types.test.ts @@ -201,7 +201,7 @@ describe("a card's engine fields in a saved canvas", () => { expect(isPipeline(back)).toBe(true) }) - test("a file from the opencode era, naming an agent, still imports", () => { + test("a file saved before cards ran on Omp, naming an agent, still imports", () => { const graph = pipeline("a") graph.nodes[0].agent.name = "flow-a" expect(isPipeline(JSON.parse(JSON.stringify(graph)))).toBe(true) diff --git a/packages/flow/src/graph/types.ts b/packages/flow/src/graph/types.ts index 81ff2948eb..e034ae2d44 100644 --- a/packages/flow/src/graph/types.ts +++ b/packages/flow/src/graph/types.ts @@ -41,7 +41,7 @@ export type Attachment = { export type FlowAgent = { /** - * An opencode agent name, from before cards ran on Omp. Tolerated on parse so + * An agent name from before cards ran on Omp. Tolerated on parse so * an old file still loads; nothing reads it. */ name?: string @@ -217,7 +217,7 @@ export type QuestionExchange = { } /** - * Token counts exactly as `opencode serve` reported them for a settled step. + * Token counts exactly as omp reported them for a settled step. * * `input` excludes cached tokens (they are counted under `cacheRead` / * `cacheWrite`) and `output` excludes reasoning, matching the server's own @@ -337,7 +337,7 @@ export type RunNodeLog = { /** Attachment names actually sent, and those the model could not read. */ attachments?: { sent: string[]; skipped: string[] } model?: string - /** An opencode agent name, from a run logged before cards ran on Omp. Never written now. */ + /** An agent name from a run logged before cards ran on Omp. Never written now. */ agent?: string /** The text actually sent to the session: uplifted, with context and memory, when those ran. */ prompt?: string diff --git a/packages/flow/src/graph/validate.test.ts b/packages/flow/src/graph/validate.test.ts index 1209c7d133..f997f79a13 100644 --- a/packages/flow/src/graph/validate.test.ts +++ b/packages/flow/src/graph/validate.test.ts @@ -127,66 +127,66 @@ describe("preflight", () => { }) test("a cycle blocks", () => { - const result = preflight(withModel(pipeline("a->b", "b->c", "c->a"), "opencode/x"), unlocked("opencode/x")) + const result = preflight(withModel(pipeline("a->b", "b->c", "c->a"), "test/x"), unlocked("test/x")) expect(result.blocking.some((problem) => problem.kind === "structure")).toBe(true) }) test("an explicit pipeline mode behaves exactly like an absent one", () => { - const graph = withModel(pipeline("a->b"), "opencode/x") - expect(preflight({ ...graph, mode: "pipeline" }, unlocked("opencode/x"))).toEqual( - preflight(graph, unlocked("opencode/x")), + const graph = withModel(pipeline("a->b"), "test/x") + expect(preflight({ ...graph, mode: "pipeline" }, unlocked("test/x"))).toEqual( + preflight(graph, unlocked("test/x")), ) }) test("an unconnected card only warns in pipeline mode — a swarm's peers carry no edges", () => { - const graph = withModel(pipeline("a->b", "loner"), "opencode/x") - expect(preflight(graph, unlocked("opencode/x")).warnings.map((problem) => problem.kind)).toEqual(["isolated"]) - expect(preflight(swarm(graph), unlocked("opencode/x")).warnings.map((problem) => problem.kind)).not.toContain( + const graph = withModel(pipeline("a->b", "loner"), "test/x") + expect(preflight(graph, unlocked("test/x")).warnings.map((problem) => problem.kind)).toEqual(["isolated"]) + expect(preflight(swarm(graph), unlocked("test/x")).warnings.map((problem) => problem.kind)).not.toContain( "isolated", ) }) test("a swarm runs on its node list — no synthesizer means nothing writes the verdict", () => { - const graph = swarm(withModel(pipeline("a", "b"), "opencode/x"), { synthesizer: false }) - expect(preflight(graph, unlocked("opencode/x")).blocking.map((problem) => problem.kind)).toEqual(["no-synthesizer"]) + const graph = swarm(withModel(pipeline("a", "b"), "test/x"), { synthesizer: false }) + expect(preflight(graph, unlocked("test/x")).blocking.map((problem) => problem.kind)).toEqual(["no-synthesizer"]) }) test("a swarm of one has nobody to debate", () => { - const graph = swarm(withModel(pipeline("a"), "opencode/x")) - expect(preflight(graph, unlocked("opencode/x")).blocking.map((problem) => problem.kind)).toEqual([ + const graph = swarm(withModel(pipeline("a"), "test/x")) + expect(preflight(graph, unlocked("test/x")).blocking.map((problem) => problem.kind)).toEqual([ "swarm-too-small", ]) }) test("two synthesizers block, naming the extra card", () => { - const graph = swarm(withModel(pipeline("a", "b", "s2"), "opencode/x")) + const graph = swarm(withModel(pipeline("a", "b", "s2"), "test/x")) graph.nodes[2].role = "synthesizer" // The first synthesizer in node order is the one the engine would run, so // every one after it is what preflight points at. - const result = preflight(graph, unlocked("opencode/x")) + const result = preflight(graph, unlocked("test/x")) expect(result.blocking.map((problem) => [problem.kind, problem.nodeId])).toEqual([ ["duplicate-synthesizer", "verdict"], ]) }) test("a well-formed swarm passes, and its leftover wiring only warns", () => { - const clean = swarm(withModel(pipeline("a", "b"), "opencode/x")) - expect(preflight(clean, unlocked("opencode/x")).blocking).toEqual([]) - expect(preflight(clean, unlocked("opencode/x")).warnings).toEqual([]) + const clean = swarm(withModel(pipeline("a", "b"), "test/x")) + expect(preflight(clean, unlocked("test/x")).blocking).toEqual([]) + expect(preflight(clean, unlocked("test/x")).warnings).toEqual([]) // Switching a pipeline to swarm keeps its edges, which now do nothing. - const rewired = swarm(withModel(pipeline("a->b"), "opencode/x")) - expect(preflight(rewired, unlocked("opencode/x")).blocking).toEqual([]) - expect(preflight(rewired, unlocked("opencode/x")).warnings.map((problem) => problem.kind)).toEqual([ + const rewired = swarm(withModel(pipeline("a->b"), "test/x")) + expect(preflight(rewired, unlocked("test/x")).blocking).toEqual([]) + expect(preflight(rewired, unlocked("test/x")).warnings.map((problem) => problem.kind)).toEqual([ "ignored-edges", ]) }) test("peers with nothing to tell them apart warn — same role, model and instructions", () => { - const graph = swarm(withModel(pipeline("a", "b", "c"), "opencode/x")) + const graph = swarm(withModel(pipeline("a", "b", "c"), "test/x")) for (const node of graph.nodes.slice(0, 3)) node.role = "coder" - const result = preflight(graph, unlocked("opencode/x")) + const result = preflight(graph, unlocked("test/x")) expect(result.blocking).toEqual([]) expect(result.warnings.map((problem) => problem.kind)).toEqual(["identical-peers"]) expect(result.warnings[0].message).toContain("coder, coder, coder") @@ -197,27 +197,27 @@ describe("preflight", () => { // Role is the axis the palette hands the user, but it is not the only one: // a different model or different instructions is a real reason to disagree, // and warning about those would train the user to ignore the warning. - const byRole = swarm(withModel(pipeline("a", "b"), "opencode/x")) - expect(preflight(byRole, unlocked("opencode/x")).warnings).toEqual([]) + const byRole = swarm(withModel(pipeline("a", "b"), "test/x")) + expect(preflight(byRole, unlocked("test/x")).warnings).toEqual([]) - const byModel = swarm(withModel(pipeline("a", "b"), "opencode/x")) + const byModel = swarm(withModel(pipeline("a", "b"), "test/x")) for (const node of byModel.nodes.slice(0, 2)) node.role = "coder" - byModel.nodes[1].agent.model = "opencode/y" - expect(preflight(byModel, unlocked("opencode/x", "opencode/y")).warnings).toEqual([]) + byModel.nodes[1].agent.model = "test/y" + expect(preflight(byModel, unlocked("test/x", "test/y")).warnings).toEqual([]) - const byPrompt = swarm(withModel(pipeline("a", "b"), "opencode/x")) + const byPrompt = swarm(withModel(pipeline("a", "b"), "test/x")) for (const node of byPrompt.nodes.slice(0, 2)) node.role = "coder" byPrompt.nodes[1].agent.prompt = "argue for the simplest thing that works" - expect(preflight(byPrompt, unlocked("opencode/x")).warnings).toEqual([]) + expect(preflight(byPrompt, unlocked("test/x")).warnings).toEqual([]) }) test("identical peers are grouped, so two twins and a third card warn once", () => { - const graph = swarm(withModel(pipeline("a", "b", "c"), "opencode/x")) + const graph = swarm(withModel(pipeline("a", "b", "c"), "test/x")) graph.nodes[0].role = "coder" graph.nodes[1].role = "coder" graph.nodes[2].role = "architect" - const warnings = preflight(graph, unlocked("opencode/x")).warnings + const warnings = preflight(graph, unlocked("test/x")).warnings expect(warnings.map((problem) => problem.kind)).toEqual(["identical-peers"]) expect(warnings[0].message).toContain("coder, coder") expect(warnings[0].message).not.toContain("architect") @@ -228,21 +228,21 @@ describe("preflight", () => { // running the same model as the debate is normal. Only the debating cards // can fail to disagree with each other, so the count the user is asked to // act on is the peers', not the whole canvas'. - const graph = swarm(withModel(pipeline("a", "b"), "opencode/x")) + const graph = swarm(withModel(pipeline("a", "b"), "test/x")) graph.nodes[0].role = "coder" graph.nodes[1].role = "coder" - const warnings = preflight(graph, unlocked("opencode/x")).warnings + const warnings = preflight(graph, unlocked("test/x")).warnings expect(warnings.map((problem) => problem.kind)).toEqual(["identical-peers"]) expect(warnings[0].message).toContain("2 sessions") }) test("a peer that can write files warns — peers run at once in one directory", () => { - const graph = swarm(withModel(pipeline("a", "b"), "opencode/x")) + const graph = swarm(withModel(pipeline("a", "b"), "test/x")) graph.nodes[0].role = "coder" graph.nodes[0].agent.tools = { read: true, edit: true, bash: false } - const warnings = preflight(graph, unlocked("opencode/x")).warnings + const warnings = preflight(graph, unlocked("test/x")).warnings expect(warnings.map((problem) => problem.kind)).toEqual(["swarm-writers"]) expect(warnings[0].message).toStartWith("coder can write files") }) @@ -250,30 +250,30 @@ describe("preflight", () => { test("an unlisted tool inherits the default agent's allow, so `{ read: true }` is a writer", () => { // `permissionBlock` writes a rule only for tools the map names; edit and // bash left unnamed stay at the default agent's allow. - const graph = swarm(withModel(pipeline("a", "b"), "opencode/x")) + const graph = swarm(withModel(pipeline("a", "b"), "test/x")) graph.nodes[1].agent.tools = { read: true } - expect(preflight(graph, unlocked("opencode/x")).warnings.map((problem) => problem.kind)).toEqual([ + expect(preflight(graph, unlocked("test/x")).warnings.map((problem) => problem.kind)).toEqual([ "swarm-writers", ]) }) test("bash alone is enough to write, so denying edit does not clear the warning", () => { - const graph = swarm(withModel(pipeline("a", "b"), "opencode/x")) + const graph = swarm(withModel(pipeline("a", "b"), "test/x")) graph.nodes[1].agent.tools = { read: true, edit: false, bash: true } - expect(preflight(graph, unlocked("opencode/x")).warnings.map((problem) => problem.kind)).toEqual([ + expect(preflight(graph, unlocked("test/x")).warnings.map((problem) => problem.kind)).toEqual([ "swarm-writers", ]) }) test("the synthesizer may write — it runs alone, after every round", () => { - const graph = swarm(withModel(pipeline("a", "b"), "opencode/x")) + const graph = swarm(withModel(pipeline("a", "b"), "test/x")) graph.nodes[2].agent.tools = { read: true, edit: true, bash: true } - expect(preflight(graph, unlocked("opencode/x")).warnings).toEqual([]) + expect(preflight(graph, unlocked("test/x")).warnings).toEqual([]) }) test("a cycle left behind by a pipeline does not block a swarm — swarm reads no edges", () => { - const graph = swarm(withModel(pipeline("a->b", "b->a"), "opencode/x")) - expect(preflight(graph, unlocked("opencode/x")).blocking).toEqual([]) + const graph = swarm(withModel(pipeline("a->b", "b->a"), "test/x")) + expect(preflight(graph, unlocked("test/x")).blocking).toEqual([]) }) /** Turns a built graph into an orchestration with the caps the run will use. */ @@ -282,20 +282,20 @@ describe("preflight", () => { } test("an orchestrator with its subagents wired under it passes", () => { - const graph = orchestrated(withModel(pipeline("root->a", "root->b"), "opencode/x")) - const result = preflight(graph, unlocked("opencode/x")) + const graph = orchestrated(withModel(pipeline("root->a", "root->b"), "test/x")) + const result = preflight(graph, unlocked("test/x")) expect(result.blocking).toEqual([]) expect(result.warnings).toEqual([]) }) test("a card with nobody to dispatch to blocks — that is a pipeline of one", () => { - const graph = orchestrated(withModel(pipeline("root"), "opencode/x")) - expect(preflight(graph, unlocked("opencode/x")).blocking.map((problem) => problem.kind)).toEqual(["no-subagents"]) + const graph = orchestrated(withModel(pipeline("root"), "test/x")) + expect(preflight(graph, unlocked("test/x")).blocking.map((problem) => problem.kind)).toEqual(["no-subagents"]) }) test("a second card nothing points at blocks, because a run has one result", () => { - const graph = orchestrated(withModel(pipeline("root->a", "other->b"), "opencode/x")) - const result = preflight(graph, unlocked("opencode/x")) + const graph = orchestrated(withModel(pipeline("root->a", "other->b"), "test/x")) + const result = preflight(graph, unlocked("test/x")) expect(result.blocking.map((problem) => [problem.kind, problem.nodeId])).toEqual([ ["duplicate-orchestrator", "other"], ]) @@ -304,37 +304,37 @@ describe("preflight", () => { test("a diamond blocks — a card answers to exactly one orchestrator", () => { // Two parents means the second dispatch would re-prompt a session that is // still working on the first one's task. - const graph = orchestrated(withModel(pipeline("root->a", "root->b", "a->shared", "b->shared"), "opencode/x")) - const result = preflight(graph, unlocked("opencode/x")) + const graph = orchestrated(withModel(pipeline("root->a", "root->b", "a->shared", "b->shared"), "test/x")) + const result = preflight(graph, unlocked("test/x")) expect(result.blocking.map((problem) => [problem.kind, problem.nodeId])).toEqual([ ["shared-subagent", "shared"], ]) }) test("a tree deeper than the depth cap blocks, naming both numbers", () => { - const graph = orchestrated(withModel(pipeline("root->a", "a->b", "b->c"), "opencode/x"), { depth: 2 }) - const result = preflight(graph, unlocked("opencode/x")) + const graph = orchestrated(withModel(pipeline("root->a", "a->b", "b->c"), "test/x"), { depth: 2 }) + const result = preflight(graph, unlocked("test/x")) expect(result.blocking.map((problem) => problem.kind)).toEqual(["too-deep"]) expect(result.blocking[0].message).toContain("3 level(s) deep") expect(result.blocking[0].message).toContain("limit is 2") // Raising the cap is the other half of the fix, and it works. - expect(preflight(orchestrated(graph, { depth: 3 }), unlocked("opencode/x")).blocking).toEqual([]) + expect(preflight(orchestrated(graph, { depth: 3 }), unlocked("test/x")).blocking).toEqual([]) }) test("a cycle is still a cycle, whatever the mode calls its edges", () => { - const graph = orchestrated(withModel(pipeline("root->a", "a->b", "b->a"), "opencode/x")) - expect(preflight(graph, unlocked("opencode/x")).blocking.map((problem) => problem.kind)).toEqual(["structure"]) + const graph = orchestrated(withModel(pipeline("root->a", "a->b", "b->a"), "test/x")) + expect(preflight(graph, unlocked("test/x")).blocking.map((problem) => problem.kind)).toEqual(["structure"]) }) test("a graph that can fan out past a dozen sessions warns before it is run", () => { // Depth multiplies: four cards under the root, each dispatchable three // times, is the number a user cannot work out by looking at the canvas. const graph = orchestrated( - withModel(pipeline("root->a", "root->b", "root->c", "root->d"), "opencode/x"), + withModel(pipeline("root->a", "root->b", "root->c", "root->d"), "test/x"), { dispatches: 3 }, ) - const result = preflight(graph, unlocked("opencode/x")) + const result = preflight(graph, unlocked("test/x")) expect(result.blocking).toEqual([]) expect(result.warnings.map((problem) => problem.kind)).toEqual(["fan-out"]) expect(result.warnings[0].message).toContain("13 sessions") @@ -382,7 +382,7 @@ describe("preflight", () => { expect(result.blocking[0].message).toContain("'a'") }) - test("a legacy opencode agent name on a node is tolerated and changes nothing", () => { + test("a legacy agent name on a node is tolerated and changes nothing", () => { const graph = pipeline("a") graph.nodes[0].agent.name = "reviewer" expect(preflight(graph, unlocked()).blocking.map((problem) => problem.kind)).toEqual(["no-model"]) @@ -390,7 +390,7 @@ describe("preflight", () => { test("a model that no logged-in provider can run blocks, names the node and says where to log in", () => { const graph = withModel(pipeline("a"), "groq/llama") - const result = preflight(graph, unlocked("opencode/x")) + const result = preflight(graph, unlocked("test/x")) expect(result.blocking.map((problem) => problem.kind)).toEqual(["locked-model"]) expect(result.blocking[0].nodeId).toBe("a") expect(result.blocking[0].message).toBe( @@ -399,30 +399,30 @@ describe("preflight", () => { }) test("a fully wired graph on unlocked models has no blocking problems", () => { - const graph = withModel(pipeline("a->b", "b->c"), "opencode/x") - const result = preflight(graph, unlocked("opencode/x")) + const graph = withModel(pipeline("a->b", "b->c"), "test/x") + const result = preflight(graph, unlocked("test/x")) expect(result.blocking).toEqual([]) }) test("a node that sets no tool permissions warns but does not block", () => { // The launch spec carries a tool list only from `agent.tools`, so a node // that declares none runs with every tool — edit and bash included. - const graph = withModel(pipeline("a"), "opencode/x") + const graph = withModel(pipeline("a"), "test/x") graph.nodes[0].agent.tools = undefined - const result = preflight(graph, unlocked("opencode/x")) + const result = preflight(graph, unlocked("test/x")) expect(result.blocking).toEqual([]) expect(result.warnings.map((problem) => problem.kind)).toEqual(["unrestricted-write"]) }) test("declaring tools is what restricts a node, so it silences the warning", () => { - const graph = withModel(pipeline("a"), "opencode/x") + const graph = withModel(pipeline("a"), "test/x") graph.nodes[0].agent.tools = { bash: true } - expect(preflight(graph, unlocked("opencode/x")).warnings).toEqual([]) + expect(preflight(graph, unlocked("test/x")).warnings).toEqual([]) }) test("an isolated node in a multi-node graph warns", () => { - const graph = withModel(pipeline("a->b", "lonely"), "opencode/x") - const result = preflight(graph, unlocked("opencode/x")) + const graph = withModel(pipeline("a->b", "lonely"), "test/x") + const result = preflight(graph, unlocked("test/x")) expect(result.warnings.map((problem) => [problem.kind, problem.nodeId])).toEqual([["isolated", "lonely"]]) }) @@ -430,30 +430,30 @@ describe("preflight", () => { // The key carries the node id, so this only happens if two nodes are given // the same id — but a collision would share one state directory between // them, which is worth refusing rather than running. - const graph = withModel(pipeline("a", "b"), "opencode/x") + const graph = withModel(pipeline("a", "b"), "test/x") graph.nodes[1].id = graph.nodes[0].id graph.nodes[1].role = graph.nodes[0].role - const result = preflight(graph, unlocked("opencode/x")) + const result = preflight(graph, unlocked("test/x")) expect(result.blocking.map((problem) => problem.kind)).toEqual(["duplicate-agent"]) expect(result.blocking[0].message).toContain("test-a-a") }) test("two nodes sharing only a role are fine", () => { - const graph = withModel(pipeline("a->b"), "opencode/x") + const graph = withModel(pipeline("a->b"), "test/x") graph.nodes[1].role = graph.nodes[0].role - expect(preflight(graph, unlocked("opencode/x")).blocking).toEqual([]) + expect(preflight(graph, unlocked("test/x")).blocking).toEqual([]) }) test("a lone single node is not treated as isolated", () => { - const graph = withModel(pipeline("a"), "opencode/x") - expect(preflight(graph, unlocked("opencode/x")).warnings).toEqual([]) + const graph = withModel(pipeline("a"), "test/x") + expect(preflight(graph, unlocked("test/x")).warnings).toEqual([]) }) test("a reachable engine still blocks a model no logged-in provider can run", () => { const graph = withModel(pipeline("a"), "groq/llama") - const result = preflight(graph, unlocked("opencode/x")) + const result = preflight(graph, unlocked("test/x")) expect(result.blocking.map((problem) => problem.kind)).toEqual(["locked-model"]) }) }) @@ -554,22 +554,22 @@ describe("preflight: gauntlet", () => { function gauntlet(spec: string[], settings: Pipeline["gauntlet"] = { bar: "beat the reference build" }) { return { - ...withModel(pipeline(...spec), "opencode/x"), + ...withModel(pipeline(...spec), "test/x"), mode: "orchestration" as const, gauntlet: settings, } } const kinds = (graph: Pipeline) => ({ - blocking: preflight(graph, unlocked("opencode/x")).blocking.map((problem) => problem.kind), - warnings: preflight(graph, unlocked("opencode/x")).warnings.map((problem) => problem.kind), + blocking: preflight(graph, unlocked("test/x")).blocking.map((problem) => problem.kind), + warnings: preflight(graph, unlocked("test/x")).warnings.map((problem) => problem.kind), }) test("a builder and a critic under one orchestrator passes, with the caps stated", () => { const result = kinds(gauntlet(["root->builder", "root->reviewer"])) expect(result.blocking).toEqual([]) expect(result.warnings).toEqual(["gauntlet-cost"]) - expect(preflight(gauntlet(["root->builder", "root->reviewer"]), unlocked("opencode/x")).warnings[0].message).toContain( + expect(preflight(gauntlet(["root->builder", "root->reviewer"]), unlocked("test/x")).warnings[0].message).toContain( "$5", ) }) @@ -622,13 +622,13 @@ describe("preflight: verification", () => { ...pipeline(...spec), nodes: pipeline(...spec).nodes.map((node) => ({ ...node, - agent: { ...node.agent, model: "opencode/x", tools: { read: true } }, + agent: { ...node.agent, model: "test/x", tools: { read: true } }, })), verify, }) const kinds = (target: Pipeline) => ({ - blocking: preflight(target, { unlockedModels: new Set(["opencode/x"]) }).blocking.map((problem) => problem.kind), - warnings: preflight(target, { unlockedModels: new Set(["opencode/x"]) }).warnings.map((problem) => problem.kind), + blocking: preflight(target, { unlockedModels: new Set(["test/x"]) }).blocking.map((problem) => problem.kind), + warnings: preflight(target, { unlockedModels: new Set(["test/x"]) }).warnings.map((problem) => problem.kind), }) test("blocks when nothing on the canvas has the reviewer role", () => { @@ -662,10 +662,10 @@ describe("preflight: verification", () => { }) describe("preflight: a verifier judging its own answer", () => { - const unlocked = { unlockedModels: new Set(["opencode/x"]) } + const unlocked = { unlockedModels: new Set(["test/x"]) } const withModel = (graph: Pipeline) => ({ ...graph, - nodes: graph.nodes.map((node) => ({ ...node, agent: { ...node.agent, model: "opencode/x", tools: { read: true } } })), + nodes: graph.nodes.map((node) => ({ ...node, agent: { ...node.agent, model: "test/x", tools: { read: true } } })), }) test("warns when the only reviewer is also the card whose output ends the run", () => { diff --git a/packages/flow/src/server/engine.test.ts b/packages/flow/src/server/engine.test.ts index ac87612ca7..ffa7b7722c 100644 --- a/packages/flow/src/server/engine.test.ts +++ b/packages/flow/src/server/engine.test.ts @@ -506,8 +506,8 @@ describe("validation", () => { test("fails every node when a model is not one the server offers", async () => { const graph = pipeline("a->b") - graph.nodes[0].agent.model = "opencode/ghost-model" - const h = harness({ models: ["opencode/real-model"] }) + graph.nodes[0].agent.model = "test/ghost-model" + const h = harness({ models: ["test/real-model"] }) // This model is missing on purpose, so skip the re-read that exists for a // catalog still filling after a restart — there is nothing to wait for. @@ -520,10 +520,10 @@ describe("validation", () => { test("re-reads a catalog that was still filling instead of calling the model unknown", async () => { const graph = pipeline("a") - graph.nodes[0].agent.model = "opencode/real-model" + graph.nodes[0].agent.model = "test/real-model" // A restarted engine answers /api/health before its catalog is complete, so // the first read is short and the model looks like it does not exist. - const h = harness({ models: [], modelsRefilled: ["opencode/real-model"] }) + const h = harness({ models: [], modelsRefilled: ["test/real-model"] }) const log = await h.run(graph, "do the thing", { catalogRetry: 1 }).done @@ -534,8 +534,8 @@ describe("validation", () => { test("accepts a model the server does offer", async () => { const graph = pipeline("a") - graph.nodes[0].agent.model = "opencode/real-model" - const h = harness({ models: ["opencode/real-model"] }) + graph.nodes[0].agent.model = "test/real-model" + const h = harness({ models: ["test/real-model"] }) const log = await h.run(graph).done @@ -1114,8 +1114,8 @@ describe("attachments", () => { test("sends run files to a model that reads them", async () => { const graph = pipeline("a") - graph.nodes[0].agent.model = "opencode/sees" - const h = harness({ models: ["opencode/sees"], vision: ["opencode/sees"] }) + graph.nodes[0].agent.model = "test/sees" + const h = harness({ models: ["test/sees"], vision: ["test/sees"] }) const log = await h.run(graph, "look", { attachments: [png] }).done @@ -1126,8 +1126,8 @@ describe("attachments", () => { test("withholds an image from a blind model and names it in the prompt instead", async () => { const graph = pipeline("a") - graph.nodes[0].agent.model = "opencode/blind" - const h = harness({ models: ["opencode/blind"] }) + graph.nodes[0].agent.model = "test/blind" + const h = harness({ models: ["test/blind"] }) const log = await h.run(graph, "look", { attachments: [png, note] }).done @@ -1140,9 +1140,9 @@ describe("attachments", () => { test("one blind node does not stop the nodes behind it", async () => { const graph = pipeline("a->b") - graph.nodes[0].agent.model = "opencode/blind" - graph.nodes[1].agent.model = "opencode/sees" - const h = harness({ models: ["opencode/blind", "opencode/sees"], vision: ["opencode/sees"] }) + graph.nodes[0].agent.model = "test/blind" + graph.nodes[1].agent.model = "test/sees" + const h = harness({ models: ["test/blind", "test/sees"], vision: ["test/sees"] }) const log = await h.run(graph, "look", { attachments: [png] }).done @@ -1153,9 +1153,9 @@ describe("attachments", () => { test("a card's own files are sent on top of the run's", async () => { const graph = pipeline("a") - graph.nodes[0].agent.model = "opencode/sees" + graph.nodes[0].agent.model = "test/sees" graph.nodes[0].agent.attachments = [note] - const h = harness({ models: ["opencode/sees"], vision: ["opencode/sees"] }) + const h = harness({ models: ["test/sees"], vision: ["test/sees"] }) await h.run(graph, "look", { attachments: [png] }).done @@ -1435,9 +1435,9 @@ describe("resume", () => { test("a reused node's model no longer being offered does not fail the run", async () => { const graph = pipeline("a->b") - graph.nodes[0].agent.model = "opencode/retired-model" - graph.nodes[1].agent.model = "opencode/real-model" - const h = harness({ models: ["opencode/real-model"] }) + graph.nodes[0].agent.model = "test/retired-model" + graph.nodes[1].agent.model = "test/real-model" + const h = harness({ models: ["test/real-model"] }) const log = await h.run(graph, "do the thing", { catalogRetry: 0, resume: { a: "a from last time" } }).done @@ -4416,7 +4416,7 @@ describe("what a session is created with", () => { model: "openai/gpt-x", tools: { write: true, bash: false, ghost: true }, mcp: ["docs"], - // A name from the opencode era is tolerated and never used. + // An agent name from before cards ran on Omp is tolerated and never used. name: "flow-a", } const h = harness({ models: ["openai/gpt-x"] }) diff --git a/packages/flow/src/state.test.ts b/packages/flow/src/state.test.ts index e604189dd3..a6f9ce92ce 100644 --- a/packages/flow/src/state.test.ts +++ b/packages/flow/src/state.test.ts @@ -49,15 +49,15 @@ describe("nodes", () => { }) test("applies the default model to a preset that has none, when it is available", () => { - setAvailableModels(new Set(["opencode/grok-code-free"])) - setDefaultModel("opencode/grok-code-free") + setAvailableModels(new Set(["test/grok-code-free"])) + setDefaultModel("test/grok-code-free") const [id] = graph("coder") - expect(state.pipeline.nodes.find((node) => node.id === id)!.agent.model).toBe("opencode/grok-code-free") + expect(state.pipeline.nodes.find((node) => node.id === id)!.agent.model).toBe("test/grok-code-free") }) test("leaves the model blank when the default is not currently available", () => { - setAvailableModels(new Set(["opencode/other"])) - setDefaultModel("opencode/grok-code-free") + setAvailableModels(new Set(["test/other"])) + setDefaultModel("test/grok-code-free") const [id] = graph("coder") expect(state.pipeline.nodes.find((node) => node.id === id)!.agent.model).toBeUndefined() }) @@ -96,12 +96,12 @@ describe("nodes", () => { const [id] = graph("planner") actions.moveNode(id, { x: 40, y: 90 }) actions.updateNode(id, { role: "renamed" }) - actions.updateAgent(id, { model: "opencode/x" }) + actions.updateAgent(id, { model: "test/x" }) const node = state.pipeline.nodes[0] expect(node.position).toEqual({ x: 40, y: 90 }) expect(node.role).toBe("renamed") - expect(node.agent.model).toBe("opencode/x") + expect(node.agent.model).toBe("test/x") }) }) diff --git a/packages/flow/src/ui/icons.tsx b/packages/flow/src/ui/icons.tsx index fb99845301..fe51bc8968 100644 --- a/packages/flow/src/ui/icons.tsx +++ b/packages/flow/src/ui/icons.tsx @@ -1,7 +1,7 @@ /** * The icon set the interface needs, inline. * - * opencode's app pulls these from `@opencode-ai/ui`, which brings a whole + * The upstream app pulls these from its UI package, which brings a whole * component library (Kobalte, tailwind, an icon sprite) with it. OpenFlow is a * single vite page with hand-written CSS, so the shapes are redrawn here at the * same 16px stroke weight rather than dragging that dependency in for a dozen @@ -85,7 +85,7 @@ export function IconPlus() { ) } -/** Save — a disk, the same shape opencode uses for a write action. */ +/** Save — a disk, the same shape the upstream app uses for a write action. */ export function IconSave() { return ( From 2fdc5ab1057268b8a0fc33e7f752e62352521a76 Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:22:14 +0000 Subject: [PATCH 13/49] chore(flow): add the no-opencode gate and bump to 1.4.0 check:no-opencode walks the fork-owned surfaces and fails on any reference outside a reasoned allowlist; vendored upstream packages are not scanned. Refs SPE-16068 --- bun.lock | 2 +- packages/flow/package.json | 5 +- .../flow/scripts/check-no-opencode.test.ts | 146 ++++++++++++++++++ packages/flow/scripts/check-no-opencode.ts | 143 +++++++++++++++++ 4 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 packages/flow/scripts/check-no-opencode.test.ts create mode 100644 packages/flow/scripts/check-no-opencode.ts diff --git a/bun.lock b/bun.lock index 8cfd9ea91a..10c7caa906 100644 --- a/bun.lock +++ b/bun.lock @@ -493,7 +493,7 @@ }, "packages/flow": { "name": "@openflow/flow", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "@lydell/node-pty": "catalog:", "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", diff --git a/packages/flow/package.json b/packages/flow/package.json index dec27a6c02..aa7e1f0482 100644 --- a/packages/flow/package.json +++ b/packages/flow/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@openflow/flow", - "version": "1.3.0", + "version": "1.4.0", "description": "OpenFlow — visual builder for multi-agent workflows", "private": true, "type": "module", @@ -13,7 +13,8 @@ "serve": "bun server.ts", "test": "bun test", "typecheck": "tsgo --noEmit", - "evals": "bun evals/score.ts" + "evals": "bun evals/score.ts", + "check:no-opencode": "bun scripts/check-no-opencode.ts" }, "dependencies": { "@lydell/node-pty": "catalog:", diff --git a/packages/flow/scripts/check-no-opencode.test.ts b/packages/flow/scripts/check-no-opencode.test.ts new file mode 100644 index 0000000000..2ddec18c68 --- /dev/null +++ b/packages/flow/scripts/check-no-opencode.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { ALLOWLIST, scan, type Allowed } from "./check-no-opencode" + +let root: string + +beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "openflow-gate-")) +}) + +afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }) +}) + +async function put(file: string, text: string) { + const target = path.join(root, file) + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(target, text) +} + +async function run(...argv: string[]) { + const child = Bun.spawn(["bun", path.join(import.meta.dir, "check-no-opencode.ts"), ...argv], { + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]) + return { stdout, stderr, code } +} + +describe("scan", () => { + test("a clean tree has no hits", async () => { + await put("packages/flow/src/app.ts", "export const engine = 'omp'\n") + await put("FLOW.md", "# rules\n") + const report = await scan(root, []) + expect(report.hits).toEqual([]) + expect(report.allowedFiles).toEqual([]) + }) + + test("a stray mention is a hit at its file and line, in any case", async () => { + await put("packages/flow/src/app.ts", "const a = 1\nconst b = 2\n// talks to OpenCode here\n") + const report = await scan(root, []) + expect(report.hits).toEqual([{ file: "packages/flow/src/app.ts", line: 3, text: "// talks to OpenCode here" }]) + }) + + test("every fork-owned surface is read", async () => { + await put("packages/flow/README.md", "opencode\n") + await put("openflow.ts", "opencode\n") + await put("openflow.sh", "opencode\n") + await put("openflow.ps1", "opencode\n") + await put(".claude/launch.json", '{"name": "opencode-server"}\n') + await put("FLOW.md", "opencode\n") + await put(".github/workflows/openflow.yml", "run: opencode\n") + await put(".openflow/pipelines/p.json", '{"model": "opencode/x"}\n') + const files = (await scan(root, [])).hits.map((hit) => hit.file).sort() + expect(files).toEqual([ + ".claude/launch.json", + ".github/workflows/openflow.yml", + ".openflow/pipelines/p.json", + "FLOW.md", + "openflow.ps1", + "openflow.sh", + "openflow.ts", + "packages/flow/README.md", + ]) + }) + + test("vendored packages, dependencies and build output are never read", async () => { + await put("packages/opencode/src/index.ts", "opencode\n") + await put("packages/web/page.ts", "opencode\n") + await put("packages/flow/node_modules/dep/index.js", "opencode\n") + await put("packages/flow/dist/app.js", "opencode\n") + await put(".openflow/runs/run-1.json", "opencode\n") + await put("README.md", "opencode\n") + expect((await scan(root, [])).hits).toEqual([]) + }) + + test("a path that names the removed engine is a hit on line 0", async () => { + await put("packages/flow/src/opencode-client.ts", "export {}\n") + expect((await scan(root, [])).hits).toEqual([{ file: "packages/flow/src/opencode-client.ts", line: 0, text: "packages/flow/src/opencode-client.ts" }]) + }) + + test("binary files are skipped", async () => { + const target = path.join(root, "packages/flow/public/blob.dat") + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(target, Buffer.from([0, 1, 2, ...Buffer.from("opencode"), 0])) + expect((await scan(root, [])).hits).toEqual([]) + }) +}) + +describe("the allowlist", () => { + const history: Allowed = { path: "packages/flow/CHANGELOG.md", reason: "history" } + + test("excuses an exact file, and counts it", async () => { + await put("packages/flow/CHANGELOG.md", "- removed opencode\n") + const report = await scan(root, [history]) + expect(report.hits).toEqual([]) + expect(report.allowedFiles).toEqual(["packages/flow/CHANGELOG.md"]) + }) + + test("excuses a directory prefix but not a sibling that merely starts the same", async () => { + await put("packages/flow/docs/plan.md", "opencode\n") + await put("packages/flow/docs-extra/plan.md", "opencode\n") + const report = await scan(root, [{ path: "packages/flow/docs/", reason: "history" }]) + expect(report.hits.map((hit) => hit.file)).toEqual(["packages/flow/docs-extra/plan.md"]) + }) + + test("a pattern excuses only the lines it matches", async () => { + await put("FLOW.md", "a vendored fork of opencode\nthe engine is opencode serve\n") + const report = await scan(root, [{ path: "FLOW.md", pattern: /vendored/, reason: "upstream fact" }]) + expect(report.hits).toEqual([{ file: "FLOW.md", line: 2, text: "the engine is opencode serve" }]) + }) + + test("reports an entry that excused nothing", async () => { + await put("packages/flow/src/app.ts", "export {}\n") + const report = await scan(root, [history]) + expect(report.unused).toEqual([history]) + }) + + test("every entry says why, and names a path inside a fork-owned surface", () => { + const surfaces = ["packages/flow/", "FLOW.md", ".github/"] + for (const entry of ALLOWLIST) { + expect(entry.reason.trim().length).toBeGreaterThan(0) + expect(surfaces.some((surface) => entry.path.startsWith(surface))).toBe(true) + } + }) +}) + +describe("the command", () => { + test("fails with one `path:line: text` per hit", async () => { + await put("packages/flow/src/app.ts", "const a = 1\n// opencode serve\n") + const result = await run(root) + expect(result.code).toBe(1) + expect(result.stderr).toContain("packages/flow/src/app.ts:2: // opencode serve") + expect(result.stdout).not.toContain("clean") + }) + + test("passes on a clean tree and says how many files were excused", async () => { + await put("packages/flow/src/app.ts", "export const engine = 'omp'\n") + await put("packages/flow/CHANGELOG.md", "- removed opencode\n") + const result = await run(root) + expect(result.code).toBe(0) + expect(result.stdout.trim()).toBe("check:no-opencode: clean (1 allowlisted files)") + }) +}) diff --git a/packages/flow/scripts/check-no-opencode.ts b/packages/flow/scripts/check-no-opencode.ts new file mode 100644 index 0000000000..1b8b4a5c8d --- /dev/null +++ b/packages/flow/scripts/check-no-opencode.ts @@ -0,0 +1,143 @@ +import { readdir, readFile } from "node:fs/promises" +import path from "node:path" + +/** + * Fails when a fork-owned file still mentions the OpenCode engine OpenFlow + * replaced with Omp. + * + * Only the fork-owned surfaces are scanned (`SURFACES`). Everything else under + * `packages/*` is a vendored upstream that says OpenCode everywhere on purpose + * and must not be touched, so it is never read. + * + * bun scripts/check-no-opencode.ts # the repo this file lives in + * bun scripts/check-no-opencode.ts # any tree laid out like the repo + * + * Exit 0 prints `check:no-opencode: clean (N allowlisted files)`. Exit 1 prints + * one `path:line: text` per hit (line 0 is the path itself). On the repo, an + * allowlist entry that no longer matches anything also fails, so the list + * cannot outlive the thing it excused. + */ + +/** One file (or a directory prefix ending in `/`) allowed to mention the removed engine, and why. */ +export type Allowed = { + /** Repo-relative, forward slashes. */ + path: string + reason: string + /** Narrows the entry to lines that match: everything else in the file is still a hit. */ + pattern?: RegExp +} + +export const ALLOWLIST: readonly Allowed[] = [ + { path: "packages/flow/CHANGELOG.md", reason: "history: released versions describe the removed engine" }, + { path: "packages/flow/GAUNTLET-FIELD-LOG.md", reason: "history: a field log written against the removed engine" }, + { path: "packages/flow/docs/", reason: "history: plans written for the removed engine, bannered as such" }, + { path: "packages/flow/src/graph/migrate.ts", reason: "legacy-pipeline migration: it has to recognise the removed engine's ids, agents and sessions" }, + { path: "packages/flow/src/graph/migrate.test.ts", reason: "legacy-pipeline migration tests: they feed it the removed engine's ids" }, + { path: "packages/flow/src/graph/__fixtures__/legacy/", reason: "documents saved by the removed engine: the migration's real input" }, + { path: "packages/flow/lib/launch.test.ts", pattern: /OPENCODE_SERVER_URL/, reason: "asserts a retired variable no longer changes the launch plan" }, + { path: "packages/flow/src/graph/prompt.test.ts", pattern: /names opencode|not\.toContain\("opencode"\)/, reason: "asserts no prompt names the removed engine" }, + { path: "packages/flow/package.json", pattern: /check:no-opencode/, reason: "the script entry that runs this gate" }, + { path: "packages/flow/scripts/check-no-opencode.ts", reason: "this script names what it looks for" }, + { path: "packages/flow/scripts/check-no-opencode.test.ts", reason: "its test plants strays to prove the gate fails" }, + { path: "FLOW.md", pattern: /vendored|upstream|check[:-]no-opencode/i, reason: "states vendored-upstream facts and names this gate" }, + { path: ".github/workflows/test.yml", reason: "upstream CI" }, + { path: ".github/actions/setup-git-committer/action.yml", reason: "upstream CI" }, + { path: ".github/ISSUE_TEMPLATE/bug-report.yml", reason: "upstream issue template" }, +] + +/** Fork-owned, repo-relative: a directory is walked, a file is read. Absent ones are skipped. */ +const SURFACES = [ + "packages/flow", + "openflow.ts", + "openflow.sh", + "openflow.ps1", + ".claude/launch.json", + "FLOW.md", + ".github", + ".openflow/pipelines", +] + +const SKIPPED_DIRECTORIES: Record = { node_modules: true, dist: true, ".git": true } +const BINARY = /\.(woff2?|ttf|otf|eot|png|jpe?g|gif|webp|ico|pdf|zip|gz)$/i +const REMOVED = /opencode/i +const SHOWN_CHARS = 200 + +export type Hit = { file: string; line: number; text: string } + +export type Report = { + hits: Hit[] + /** Files whose every mention was excused by the allowlist. */ + allowedFiles: string[] + /** Entries that excused nothing. */ + unused: Allowed[] +} + +async function walk(root: string, relative: string, found: string[]) { + const entries = await readdir(path.join(root, relative), { withFileTypes: true }).catch(() => undefined) + if (!entries) return + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const next = `${relative}/${entry.name}` + if (entry.isDirectory() && !Object.hasOwn(SKIPPED_DIRECTORIES, entry.name)) await walk(root, next, found) + if (entry.isFile() && !BINARY.test(entry.name)) found.push(next) + } +} + +async function collect(root: string) { + const found: string[] = [] + for (const surface of SURFACES) { + const entries = await readdir(path.join(root, path.dirname(surface)), { withFileTypes: true }).catch(() => []) + const entry = entries.find((candidate) => candidate.name === path.basename(surface)) + if (entry?.isDirectory()) await walk(root, surface, found) + if (entry?.isFile()) found.push(surface) + } + return found +} + +/** Every mention of the removed engine under `root` that the allowlist does not excuse. */ +export async function scan(root: string, allow: readonly Allowed[] = ALLOWLIST): Promise { + const hits: Hit[] = [] + const used = new Set() + const allowedFiles: string[] = [] + + for (const file of await collect(root)) { + const candidates = allow.filter((entry) => (entry.path.endsWith("/") ? file.startsWith(entry.path) : file === entry.path)) + let excused = false + const check = (line: number, text: string) => { + if (!REMOVED.test(text)) return + const entry = candidates.find((candidate) => !candidate.pattern || candidate.pattern.test(text)) + if (!entry) return void hits.push({ file, line, text: text.trim().slice(0, SHOWN_CHARS) }) + used.add(entry) + excused = true + } + + // The path itself counts: a file called `x.opencode.json` is as stale as a line that says so. + check(0, file) + const content = await readFile(path.join(root, file)) + if (content.includes(0)) continue + const lines = content.toString("utf8").split(/\r?\n/) + for (let index = 0; index < lines.length; index++) check(index + 1, lines[index]) + if (excused) allowedFiles.push(file) + } + + return { hits, allowedFiles, unused: allow.filter((entry) => !used.has(entry)) } +} + +const repoRoot = path.resolve(import.meta.dir, "../../..") + +export async function main(argv: string[]) { + const given = argv[0] + const report = await scan(given ? path.resolve(given) : repoRoot) + // A tree other than this repo cannot be expected to hold every allowlisted path. + const unused = given ? [] : report.unused + + for (const hit of report.hits) console.error(`${hit.file}:${hit.line}: ${hit.text}`) + for (const entry of unused) console.error(`allowlist: ${entry.path} excuses nothing; remove it (${entry.reason})`) + if (report.hits.length || unused.length) { + console.error(`check:no-opencode: ${report.hits.length} hit(s), ${unused.length} stale allowlist entr${unused.length === 1 ? "y" : "ies"}`) + return 1 + } + console.log(`check:no-opencode: clean (${report.allowedFiles.length} allowlisted files)`) + return 0 +} + +if (import.meta.main) process.exit(await main(process.argv.slice(2))) From a097b752c0b567b0ffc96cd944681349cf1b2978 Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:22:15 +0000 Subject: [PATCH 14/49] docs(flow): describe the omp engine README, FLOW.md and the changelog describe the Omp engine, Prompt Uplift, Substrate memory, Agent Swarm, the settings dialog and the terminal; older plans carry a historical banner. Refs SPE-16068 --- FLOW.md | 382 +++++----- packages/flow/CHANGELOG.md | 42 + packages/flow/README.md | 976 +++++++++++++++--------- packages/flow/docs/build-prompt.md | 3 + packages/flow/docs/plan-manual-first.md | 3 + packages/flow/docs/plan.md | 3 + 6 files changed, 853 insertions(+), 556 deletions(-) diff --git a/FLOW.md b/FLOW.md index e50ffd7b81..93f66602b8 100644 --- a/FLOW.md +++ b/FLOW.md @@ -10,21 +10,27 @@ derivable from the code. ## The one hard constraint **Do not modify any upstream package.** Fork merges must stay clean. Fork-owned surfaces -are `packages/flow`, `.github`, the LICENSE attribution line, and this file. `bun.lock` -gains only the workspace entry. +are `packages/flow`, the root launchers (`openflow.ts`, `openflow.sh`, `openflow.ps1`), +`.claude/launch.json`, the shipped templates in `.openflow/pipelines`, `.github` (only its +`workflows/openflow.yml` is the fork's; the rest is upstream CI and stays as it is), the LICENSE +attribution line, and this file. `bun.lock` gains only the workspace entry. From `packages/flow`, +`bun run check:no-opencode` fails when a fork-owned surface still mentions the removed engine; +`scripts/check-no-opencode.ts` lists the few paths allowed to (history, migration code and +fixtures, upstream CI). Corollary: **do not scrub the hundreds of `opencode` string references** in the upstream README, docs, source, `sst.config.ts`, or `infra`. They are intentional. Scrubbing them fights the no-upstream-modification design. -Read `packages/flow/README.md` before touching the code — endpoints, run instructions, -file layout, and API-key/model behavior are documented there rather than re-derived. +Read `packages/flow/README.md` before touching the code — routes, run instructions, +file layout, and login/model behavior are documented there rather than re-derived. ## Architecture, settled -- **One card = one full `opencode serve` primary session.** `src/server/engine.ts` per - node: `createSession({agent, model})` → `prompt` → `waitForIdle` → `transcript`. Nodes - run in topological layers via a parallel pool. +- **One card = one omp session = one `omp --mode rpc-ui` process.** `src/server/engine.ts` per + node: `createSession({card, model, …})` → `prompt` → `waitForIdle` → `transcript`, each a call + to the host's `/flow/api/engine/*` routes (see "Omp engine" below). The engine itself runs in + the page. Nodes run in topological layers via a parallel pool. - **A canvas has a mode, and modes do not nest.** `Pipeline.mode` is `pipeline` (absent, and what every canvas saved before modes existed reads as), `swarm`, or `orchestration`. It is a property of the *document*, not of a run — it is persisted, exported, and undoable, @@ -95,7 +101,7 @@ file layout, and API-key/model behavior are documented there rather than re-deri does not quote makes the spend cap unmeasurable, and an unenforceable cap on a run designed to go for hours is the one failure nobody would notice until the bill arrived — it stops the run rather than warning. - **Critics are opencode's own `reviewer` role**, read off the role text like a swarm reads its + **Critics are the `reviewer` role**, read off the role text like a swarm reads its synthesizer, so designating one is renaming a card. A critic gets a **new session for every verdict** (`nodeSession.delete` before dispatch): a critic that has watched the work improve grades the improvement, not the work, which is the exact failure a separate critic exists to @@ -153,7 +159,7 @@ file layout, and API-key/model behavior are documented there rather than re-deri `maxSpend`, the headline bound of a mode built to run for hours, was compared against a number drifting below what had been spent. No vision: a critic reads source, runs builds, runs tests, hits the dev server. Visual A/B - waits for MCP to reach v2 sessions — though a card with `bash` can drive a headless browser + has no built-in tool — though a card with `bash` can drive a headless browser and capture the artifact itself, which is how the runtime lines of a bar have actually been judged here. - **A run that nobody is watching says when it ended.** Every mode here is meant to be left @@ -173,7 +179,7 @@ file layout, and API-key/model behavior are documented there rather than re-deri the same shape as `graph/default-model.ts`, so `bun test` needs no storage polyfill. - **An interrupted run is picked up, not started again.** The engine runs *in the page*, so a reload, a crash or a closed tab ends a run with nothing alive to write `done`, `error` or - `stopped` — while the sessions are still on the server and still addressable. `RunOptions.sessions` + `stopped` — while the sessions are still held by the host and still addressable. `RunOptions.sessions` maps a node id to the session it was working in and is seeded before anything dispatches, so the card's first turn is prompted into that session; `interruptedNote()` tells it once, on that turn only, that the run around it stopped and nothing it produced was discarded, because @@ -184,46 +190,27 @@ file layout, and API-key/model behavior are documented there rather than re-deri run log** (`patch(node.id, { sessionID })`), because the create path that normally writes it is skipped — without it a resumed run reports cards with no session while they answer in one, and the *next* interruption has nothing left to carry. -- **No MCP tool can reach a card in this fork, so the dispatch tool is parked.** Proven - 2026-08-29, after it looked like a config bug for a long time. OpenFlow drives - `client.v2.session.*`, so a card runs through the v2 session runner, and that runner has - never been wired to MCP — its own spec comment says so, unticked, at - `packages/core/src/session/runner/llm.ts`: *"[ ] Resolve policy-filtered built-in, MCP, - plugin, and structured-output tool definitions."* MCP tools are converted and registered in - exactly one place, the **v1** session path (`packages/opencode/src/session/tools.ts`), which - OpenFlow does not use. The v2 registry (`packages/core/src/tool/`) holds read, grep, glob, - bash, edit, write, question, skill, todowrite, webfetch, websearch, apply-patch — and - nothing puts MCP beside them, so any `_` comes back `Unknown tool` from - `core/src/tool/registry.ts` however the config is written. - **`GET /mcp` answering `connected` is the trap**: the MCP *service* spawns the process and - completes the handshake perfectly well: it just never contributes a tool definition to a v2 - session. Do not read that status as "the tool works". Fixing this here is not an option — - it means editing `packages/core`. It needs an upstream change, or a fork that stops being - fork-clean. - `MCP_REACHES_SESSIONS` in `graph/dispatch.ts` is the single switch: `false` parks the - channel (the engine skips the history scan, the runbar hides the install banner, and the - orchestrator briefing teaches only the fenced block). Flipping it to `true` is the whole of - turning it back on. **Do not name the tools in a prompt while it is parked** — measured, the - card calls one, is told `Unknown tool`, and only then writes the block, which costs a paid - turn on every single run. -- **The tool channel itself is built, tested, and correct** — `mcp/dispatch.ts` (a hand-rolled - stdio MCP server; no dependency, since `bun.lock` gains only the workspace entry), - `fromToolCall`, `sessionCalls`, and `lib/dispatch-tool.ts` writing the **global** config. - Keep these working. The reason it is a tool at all is measured: a text-only protocol lost - three ways — a model sent the JSON with no fence, a model emitted the block and then kept - calling tools so the block was no longer in the message read, and a strong model tried to - *call* `dispatch` because that is what the schema in front of it looks like. - Two traps worth keeping, both found by running it: the call is read from the **message - history**, and that scan must **not** be bounded at the newest user message — a tool - *result* comes back as a user-type message, so the scan would stop before reaching the call - that produced it; consumed call ids are what separate this turn from the last. And the MCP - command must be an **absolute path to bun**, never the bare word, because opencode spawns a - local MCP server without a shell and Windows does not apply PATHEXT; `process.execPath` is - not enough either, since the vite dev host runs under node, so `bunPath()` searches PATH. +- **The orchestrator's decision is a tool call first and a fenced block second.** The host + registers `openflow_dispatch` and `openflow_finish` as omp RPC host tools for a card that + orchestrates (`lib/omp/host-tools.ts`, `CardSpec.dispatch`). They execute nothing: the engine + reads the calls back through `EngineApi.sessionCalls` (`GET …/sessions/:id/calls`, newest + first), and `fromToolCall` in `graph/dispatch.ts` turns one into the same assignments the + fenced block would give, so the two channels cannot disagree about what a valid dispatch is. + `MCP_REACHES_SESSIONS` in `graph/dispatch.ts` is the single switch the briefing, the engine and + the runbar read; it is `true`. Its name is from the era when the channel was an MCP server — it + now means "the host answers the dispatch tools", not "MCP servers reach cards". The tool names + are `DISPATCH_TOOL` / `FINISH_TOOL`, and `lib/omp/host-tools.test.ts` asserts the host registers + the same two; `lib/` does not import from `src/`, which is why the names are duplicated. +- **Why it is a tool at all** — measured: a text-only protocol lost three ways. A model sent the + JSON with no fence, a model emitted the block and then kept calling tools so the block was no + longer in the message read, and a strong model tried to *call* `dispatch` because that is what + the schema in front of it looks like. One trap worth keeping: the engine remembers which call + ids it has consumed, per card (`consumed` in `engine.ts`) — that is what separates this turn's + call from the last turn's, and without it an old call is re-read as a fresh decision. - **The orchestrator also speaks a fenced ` ```openflow ` block** — `{dispatch:[{card,task}]}` or `{final}` — parsed by `graph/dispatch.ts`. The **last** block wins, because a model often - quotes the protocol before using it. A malformed block is handed back **once** with the exact - reason; a second failure fails the card, since every ask is a paid turn. A card id outside the + quotes the protocol before using it. A malformed block is handed back with the exact reason, + up to `PROTOCOL_RETRIES` (3) times, and then fails the card, since every ask is a paid turn. A card id outside the orchestrator's own children is refused, and so is the same card twice in one batch — one card is one session, and two assignments would race it. An assignment may carry `files`, the paths the orchestrator expects that card to write; two assignments in one batch declaring the same @@ -262,8 +249,8 @@ file layout, and API-key/model behavior are documented there rather than re-deri setting entirely (`verifyOf` returns nothing) and preflight says so, because its loop already cannot finish without a verdict and a second opinion there would have no owner. - **A card is `done` when its session goes idle, so what it did is checked separately.** Idle is - all "finished" has ever meant here — `POST /api/session/:id/wait` answers 503 on this build, so - `waitForIdle` derives it from `/api/session/active` plus a finished assistant turn — and a model + all "finished" has ever meant here — the host's `POST /flow/api/engine/sessions/:id/wait` + long-polls until omp reports the session settled (`WaitResult`; `aborted` is never success) — and a model that replies "I'll start on that" and calls nothing satisfies it in one turn. Two checks stand between that and `done`, both in `runTurn` so every mode gets them. **No text and no writes fails the card**: there is nothing to pass downstream and nothing to have built, and a blank @@ -438,10 +425,9 @@ file layout, and API-key/model behavior are documented there rather than re-deri saved before it existed: turning somebody's cards loose in separate trees is not a change to make because they upgraded. The flag survives a mode switch so switching back does not lose it, and does nothing while the canvas is not an orchestration. - This is the prevention half of the rule above, and it is reachable because a session created - with `location: { directory }` runs its tools *there* — measured 2026-09-03, against the - older note in "Hazard: test runs write real files", which is about config resolution rather - than cwd. `lib/worktree.ts` opens one `git worktree` per dispatched card that can write, + This is the prevention half of the rule above, and it is reachable because a card's omp + process is started with `--cwd` set to the session's `directory` and runs its tools *there* — + measured 2026-09-03. `lib/worktree.ts` opens one `git worktree` per dispatched card that can write, branched from `git stash create` so the user's **uncommitted** work is in it — `HEAD` would hand every card a tree unlike the one on screen. Trees live in the OS temp dir, never inside the repo, and `node_modules` is linked in, because a critic that cannot run the tests judges @@ -460,19 +446,95 @@ file layout, and API-key/model behavior are documented there rather than re-deri keeps its tree for the whole run because a session's location is fixed at creation, so a re-dispatch opens nothing; it must still **merge**, or cleanup would delete that work. Cleanup runs in `finally`, since an aborted run leaves the most trees behind. -- **OpenFlow has no agent loop of its own.** The harness is identical to the OpenCode CLI - harness, so a card inherits OpenCode's tools, permission ruleset, and subagents. Subagents - work but are OpenCode-native and invisible to OpenFlow, which only sees the final - transcript. OpenFlow injects no skills and adds no Claude-Code skill/MCP layer inside a card. +- **OpenFlow has no agent loop of its own.** A card is an omp process, so it inherits omp's tools, + approval modes and subagents. Subagents (agent-swarm's specialists among them) are omp-native: + OpenFlow sees the card's final transcript and, for a swarm, a status derived from omp's + subagent frames — not the subagents' own transcripts. OpenFlow injects no skills; skills and + MCP servers come from the omp profile. - The canvas is hand-rolled — HTML node cards over an SVG edge layer in one CSS-transformed viewport. `solid-flow` is abandoned (last publish 2022, Solid 1.5); do not reach for it. -- `@opencode-ai/sdk-next` is **not** an HTTP client — it embeds a server in-process via - Effect. Browser code uses `createOpencodeClient` from `@opencode-ai/sdk/v2/client`. -- Use v2 `/api/*` routes. The older `/session/*` + `prompt_async` routes are superseded. +- The browser never talks to omp. It calls the host's `/flow/api/engine/*` routes + (`shared/engine.ts` is the wire, `src/server/client.ts` the browser client); there is no + other engine server and no SDK. - Store routes (`/flow/api/*`) live in `lib/store.ts` and are mounted by **both** hosts (the vite plugin and `server.ts`) so dev and built output cannot drift. Add new routes in one place only. +## Omp engine + +The architecture invariants of the engine that replaced the earlier one. The facts below were +established by live runs against real omp 18.12.2 and the real Agent Substrate; the code is the +authority where this and it disagree. + +- **Host module map.** `lib/host` (`createHost`) composes `lib/omp` (the engine host: one omp + process per card, the RPC core `rpc.ts`, the launch builder `launch.ts`, the model catalog, the + frame-to-event mapper, UI requests, host tools), `lib/omp-config` (the settings store, status, + `omp config`, providers, login import), `lib/uplift`, `lib/substrate`, `lib/swarm` and + `lib/terminal`. It returns one `handle` — terminal, omp-config, engine, uplift, substrate, swarm, + tried in that order — plus `profileDir()` and an idempotent `dispose()`. Both hosts (`server.ts` + and `vite/flow-store.ts`) build one `createHost`, give it first refusal of every request, and + mount `handleFlow` (the store) after it. A new host route goes in a module, never in either host. +- **The wire.** `shared/engine.ts` is the engine wire: the routes under `/flow/api/engine`, the + event names, `CardSpec`, and `EngineApi`, the port `src/server/engine.ts` depends on. + `src/server/client.ts` implements it in the browser and `engine.test.ts` doubles it, so the long + engine suite stays valid. The host maps omp frames onto the event names the engine and + `activity.ts` already read (`session.next.*`, `permission.v2.asked`, `question.v2.asked`, + `session.created`), plus `flow.swarm.status` and `flow.notice`. The stage and settings wires are + `shared/{omp,uplift,memory,swarm,terminal,clarification}.ts`. Change a wire in `shared/` first; + host and browser both import it. +- **Process model.** One `omp --mode rpc-ui` per card session, owned by the host, leading its own + process group (so tool shells and MCP servers die with it). The RPC core speaks JSONL over stdio + with protocol v2 `rpc_chunk` reassembly — mandatory, the model catalog alone arrives as about + eight chunks. Closing stdin is the graceful stop; the group is SIGTERMed and SIGKILLed on cancel, + and swept again when the host exits. A live session with no frames, no prompt and no HTTP + activity for 30 minutes is closed and stays resumable from its session directory + (`CreateSession.reuse`), which is also how a page reload keeps a run's sessions. The catalog, + the provider list and the status check each start a short-lived omp and cache the answer. +- **Stage order.** For a card turn of kind `task`, `stage()` in `src/server/engine.ts` runs Prompt + Uplift first, then the memory read, then the send; `raw:` at the start of a task and + `agent.uplift === false` skip both. A failed uplift never falls back silently: the card parks + and the user chooses (run the original, retry, cancel). The memory read fails open and shows on + the card. The memory write happens after the card settles and is never awaited by the card. + Protocol turns (verdict, force-final, dispatch result, reassign) are never uplifted, and neither + are upstream outputs or attachments — only the card's own task text. +- **Isolation decisions.** Cards, the catalog and the terminal run under the omp profile + `openflow`, so OpenFlow never reads or writes the user's default profile (the one exception is the + user-initiated login copy, `omp auth export` into `omp --profile … auth import -`, in memory + only). Cards start with `--no-extensions` unless `loadUserExtensions` is on. A card's environment + is an allowlist (`lib/omp/env.ts`): deny rules beat allow rules, so `SUBSTRATE_*`, `SWARM_*`, + `ULTRATHINK_*`, `AIO_*`, `*_TOKEN` and `*_SECRET` reach a card only when the launch builder hands + them over in `extra` — which it does for the swarm signing key (by name, only when the card's + swarm is on), `OPENFLOW_RUN`, `OPENFLOW_CARD`, `ULTRATHINK_UPLIFT=0` and `AIO_UPLIFT=0`. **No + secret goes to a card through argv, a file or `omp-settings.json`**; the Substrate token is read + by the host and never given to a card. Per-card files live outside the project, under + `/omp/<12 hex of sha1(realpath(project))>///`. Tool toggles are enforced with a + `tools.approval.: deny` overlay passed as `--config`, not `--tools`: probed against the + real binary, `--tools` shrinks the active set to the named tools, so an extension's tool + disappears unless it is listed, while a deny leaves everything else alone. The card's own denies + are merged last so no other overlay can switch a disabled tool back on. +- **The per-card MCP allowlist is not enforced.** Omp has no per-session MCP selection and no + settings-overlay switch for it; server allow and deny live in the profile's `mcp.json`. + `CardSpec.mcp` is carried and `describeLaunch` builds a note about it, but nothing surfaces that + note yet and the inspector still draws the per-server checkboxes. Treat every configured server + as visible to every card. +- **The terminal security gate.** A PTY is code execution as the user, so every terminal route + needs the `x-openflow-pty: 1` header (a custom header forces a CORS preflight this host never + answers), an `Origin` equal to the loopback `Host` it was sent to, and — for the WebSocket — a + one-shot ticket (60 seconds). The browser sends only a `kind` (`omp` or `shell`); argv, cwd and + env are host-derived, and the env is the card allowlist without `*_API_KEY` keys. It is off when + `FLOW_ALLOW_REMOTE=1`. All of this is `lib/terminal/policy.ts`, a table-driven test. +- **Bun/Node dual runtime.** `server.ts` runs under Bun; the vite dev plugin runs under Node. + Everything in `lib/` and `shared/` runs in both, so it uses `node:` APIs only — no `Bun.*` and no + `bun:` imports — and `Promise.withResolvers()` rather than an executor. What genuinely differs is + chosen once, in one file per runtime: the PTY (`lib/terminal/pty-bun.ts` uses Bun's own, because + `@lydell/node-pty` fails under Bun; `pty-node.ts` uses `@lydell/node-pty` and must stay + strippable by Node — no enums, no extensionless value imports) and the WebSocket upgrade + (`ws-bun.ts`, `ws-node.ts`). `shared/` imports nothing from `lib/`, `src/` or a runtime, and + `lib/` does not import `src/`; the engine runs in the browser and cannot import `lib/`. +- **State that tests touch.** A test that reaches OpenFlow's own state sets `OPENFLOW_STATE_DIR` + to a temp directory before the module loads; the alternative writes a developer's real + `~/.openflow/state.json`. + ## The post-run coach `graph/coach.ts` reads a finished `RunLog` and the pipeline and returns at most three `Finding`s, @@ -527,17 +589,19 @@ before running one. ## Running and verifying -- `opencode serve` on **:4096**, canvas dev server on **:5174**. Both start from - `.claude/launch.json` (`opencode-server`, `openflow`). +- The canvas dev server is on **:5174**, started from `.claude/launch.json` (`openflow`). It + hosts everything — the store and the modules that drive omp — so there is no second server to + start. - :5174 binds `localhost`/IPv6 — `127.0.0.1:5174` refuses the connection. Use `http://localhost:5174`. -- `opencode serve` takes ~30s to listen. Early `/api/*` proxy calls fail; reload rather - than debug it. -- **Running the canvas without `opencode serve` is the number-one false alarm.** Every - `/api/*` call dies and the symptom surfaces as a *provider* failure (empty provider panel, - failing key import), not an obvious "server down". The dev proxy now answers 502 naming the - missing server. -- Another session may already hold :4096 or :5174. Check `netstat` before starting your own. +- omp takes about two seconds to be ready and a few more to answer the model catalog, so the first + model read after boot is slow; the host caches it for ten minutes. Reload rather than debug it. +- **Running without a usable omp is the number-one false alarm.** A missing or too-old omp + surfaces as whatever was being done — an empty model menu, a card that errors at once, a failed + model read — not as an obvious "omp is missing". Check the statusbar (`connected · omp + `) and the Status tab of Omp settings (`GET /flow/api/omp/status`) before debugging + anything else. +- Another session may already hold :5174. Check `netstat` before starting your own. - From `packages/flow`: `bun test` and `bun run typecheck` (tsgo). Tests cannot run from the repo root. @@ -567,85 +631,48 @@ Working headless techniques: - **Menu rows are `button.oc-item[data-key="…"]`, and clicking the inner `span.oc-item-label` does nothing.** Select on the `[data-key]` button itself; a text search that lands on the label finds the row and clicks the wrong element. -- A bare `fetch('/api/agent')` reports the **server-cwd** project's agents. Add header - `x-opencode-directory: ` to see the merged pipeline agents. ## Hazard: test runs write real files -Per-node tool allowlists only take effect after the generated agents are merged into the -project `opencode.json` **and** the node names that agent. Otherwise nodes run as the server -default `build` agent with `write`/`edit`/`bash`. A verification run once took its task -literally and edited `packages/cli`. - -Before any run that could write: give it a restricted agent, and set `OPENFLOW_PROJECT` to -the target repo. - -The mechanism, measured 2026-08-26: **a session's location is the engine's cwd, never -`OPENFLOW_PROJECT`.** `supervisorFor` spawns `opencode serve` with `cwd: input.repo` — the -OpenFlow checkout (`packages/opencode` when run from source) — so every session records that -directory, and the merged project agents are simply not there at drain time. `AgentV2.select` -does not fail on an unknown id; it returns `{ id, info: undefined }`, and the runner then reads -`agent.info?.system` and `agent.info?.permissions` as undefined. A session created with the -agent name `this-agent-does-not-exist-xyz` answered normally — no node system prompt, default -tool set. `x-opencode-directory` only steers the *reads* (`/api/agent`, `/api/model`), not the -drain. Anything that must reach a run — an agent, a permission ruleset, a provider override — -therefore belongs in the global config or in the engine's own cwd, not in the target project. - -**That is about config, not about cwd — corrected 2026-09-03.** `session.create` takes -`location: { directory }`, and a session created with one **runs its tools there**: a card -given a `git worktree` path ran `bash` in that worktree, not in `packages/opencode`. So the -paragraph above still holds for *which config a drain reads* — that is resolved from the -engine's own cwd — but it is not true that a session's working directory cannot be steered. -Per-card isolation is built on exactly that (see below). - -**Skills written through `PUT /flow/api/skills/:name` are subject to the same trap.** The file -lands in `/.openflow/skills//SKILL.md` and the path is registered in the project -`opencode.json` — both correct, and both at a location no run ever reads. Measured 2026-08-26: -after a restart, `GET /api/skill` did not list the new skill, with or without -`x-opencode-directory`; copying the same folder to `~/.config/opencode/skills//` made it -appear immediately and every provider then used it. Until the engine is spawned in the project, -a skill that must reach a card has to be global. - -**An empty scoped agent list is a question about the directory, not about any name.** A project -folder the server does not recognise as one — most often a folder that is simply not a git -repository — answers `GET /api/agent` with **zero** agents, built-ins included. Measured again -2026-09-08 against a live engine: a non-git temp directory returned 0, this repo returned 49, -and the same read with no `x-opencode-directory` at all returned the same 49. Every named card -therefore reads as missing, and the message that used to be shown blamed a stale engine and -quoted a restart command — which changes nothing, and is expensive precisely because it sounds -right. The run itself was usually fine: a session resolves its config from the engine's own cwd -rather than from `OPENFLOW_PROJECT`, which is the same split this section documents for agents, -skills and provider overrides — here it surfaces in the *check* rather than in the run. -So `unknownAgents` treats an empty list as its own case and asks `agentsUnscoped()` (a second -client built with no `directory`, so it sees what the drain sees) before failing anything: if -the names are there the cards run, and only when they are not does it stop, say `git init`, and -**not** call `onEngineStale` — that dialog offers the one fix that cannot work. A non-empty list -missing one name is still the ordinary stale-config case and still says restart. `mergeAgents` -in `src/app.tsx` splits the same two cases after a write, for the same reason. A host with no -unscoped read (an older build, a test double) keeps the old message, which is the right answer -whenever the directory is a real project. +A card whose node says nothing about tools runs with every tool omp has — `edit` and `bash` +included — in the project folder. `preflight` warns (`unrestricted-write`) but does not block. A +verification run once took its task literally and edited `packages/cli`. + +Before any run that could write: give every card the tool toggles it needs (the launch spec carries +a tool list only when the card declares one), and set `OPENFLOW_PROJECT` to the target repo. + +**Where a card runs is the project, or its own worktree.** The host starts each omp process with +`--cwd` set to the session's `directory`: `OPENFLOW_PROJECT` by default, a card's `git worktree` +when isolation gave it one (see the orchestration rules above). Which config the card reads is +omp's rule for the profile and directory OpenFlow passes (`--profile`, plus a per-card `--config` +overlay); the project's own files are not a place to put card permissions. + +**Skills and MCP servers are written to the omp profile**, not the project +(`/skills//SKILL.md`, `/mcp.json`), and omp reads them when a card's +session starts. A change applies to the next session, not to one already running, and the routes +answer 409 until omp has reported the profile's directory. ## Design directive (standing) **The entire OpenFlow UI matches upstream OpenCode, and stays minimal.** Copy from upstream components rather than approximating them, and do not add extra affordances alongside them. -- Model and provider UI mirrors `packages/app/src/components/dialog-select-model.tsx` and - `dialog-connect-provider.tsx`. A fresh install shows **no models, only providers** — pick a - provider, enter a key, then its models exist. +- Model UI mirrors `packages/app/src/components/dialog-select-model.tsx`. A fresh install shows + **no models** until the OpenFlow omp profile has a login — log in (Omp settings, or `/login` + in the terminal), then its models exist. - Tokens are copied verbatim from `packages/ui/src/v2/styles/theme.css` and `colors.css` into - `:root` in `src/styles.css`, because the flow page loads no upstream stylesheet and - `@opencode-ai/ui` stays out of the dep tree. Inter 13px / weight 440 / tracking -0.04px; + `:root` in `src/styles.css`, because the flow page loads no upstream stylesheet and the vendored + UI package stays out of the dep tree. Inter 13px / weight 440 / tracking -0.04px; mono only for code, ids, and paths. Fonts are copied into `packages/flow/public/fonts/`. - Chrome is titlebar 40px / runbar 44px / statusbar 28px around a three-column `main`. -- Native `
    {runtime().activity}
    - +
    {runtime().error}
    diff --git a/packages/flow/src/ui/activity-drawer.tsx b/packages/flow/src/ui/activity-drawer.tsx index d5e258199a..813b14b046 100644 --- a/packages/flow/src/ui/activity-drawer.tsx +++ b/packages/flow/src/ui/activity-drawer.tsx @@ -95,7 +95,7 @@ export function ActivityDrawer() { {costLabel(runtime()!.usage!)} - + {(line) => ( {line().text} diff --git a/packages/flow/src/ui/card-stages-model.test.ts b/packages/flow/src/ui/card-stages-model.test.ts index 94f243aa8e..043c7ee444 100644 --- a/packages/flow/src/ui/card-stages-model.test.ts +++ b/packages/flow/src/ui/card-stages-model.test.ts @@ -11,6 +11,7 @@ import { memoryReadText, memoryRow, memoryWriteText, + settleSwarm, stageAnnouncement, stageSnapshot, swarmCountsText, @@ -285,6 +286,36 @@ describe("swarm", () => { expect(swarmIdleText(swarm({ mode: "always", active: false }))).toContain("a01-orchestrator") expect(swarmIdleText(swarm({ mode: "auto", active: false }))).toContain("no delegation yet") }) + + test("a card that is not running never shows a running swarm", () => { + const stale = swarm({ + counts: { queued: 1, running: 1, completed: 1, failed: 0, cancelled: 0 }, + agents: [ + { agent: "a01-orchestrator", state: "running", at: 1 }, + { agent: "a05-backend", state: "queued", at: 1 }, + { agent: "a08-qa", state: "completed", at: 1 }, + ], + }) + for (const status of ["stopped", "error", "done", "skipped", "idle"] as const) { + const settled = settleSwarm(stale, status) + expect(settled?.active).toBe(false) + expect(settled?.counts).toEqual({ queued: 0, running: 0, completed: 1, failed: 0, cancelled: 2 }) + expect(settled?.agents.map((agent) => agent.state)).toEqual(["cancelled", "cancelled", "completed"]) + expect(swarmLine(stale, status)?.text).toBe("swarm 1 done · 2 cancelled") + expect(swarmLine(stale, status)?.text).not.toContain("running") + expect(stageSnapshot({ swarm: stale, status }).swarm).not.toContain("running") + } + expect(settleSwarm(stale, "running")).toBe(stale) + expect(settleSwarm(stale, "queued")).toBe(stale) + expect(settleSwarm(stale, undefined)).toBe(stale) + expect(settleSwarm(undefined, "stopped")).toBeUndefined() + expect(stale.counts.running).toBe(1) + }) + + test("a swarm the host already settled is left as it is", () => { + const final = swarm({ active: false, counts: { queued: 0, running: 0, completed: 1, failed: 0, cancelled: 1 }, agents: [{ agent: "a01-orchestrator", state: "cancelled", at: 1 }] }) + expect(settleSwarm(final, "stopped")).toBe(final) + }) }) describe("announcements", () => { diff --git a/packages/flow/src/ui/card-stages-model.ts b/packages/flow/src/ui/card-stages-model.ts index 685da5fe97..588e831663 100644 --- a/packages/flow/src/ui/card-stages-model.ts +++ b/packages/flow/src/ui/card-stages-model.ts @@ -1,6 +1,7 @@ import type { MemoryInfo } from "../../shared/memory" import type { SwarmAgentState, SwarmStatus } from "../../shared/swarm" import type { UpliftDecision, UpliftInfo } from "../../shared/uplift" +import type { NodeStatus } from "../graph/types" /** * Every string the uplift / memory / swarm surfaces show, as data. @@ -300,6 +301,19 @@ export function swarmCountsText(counts: SwarmStatus["counts"]) { return parts.join(" · ") } +/** + * A swarm's agents only run while their card does. Once the card is anything + * but running or queued, what the host last said may be stale (the final + * status can arrive late or never), so open agents read as cancelled. + */ +export function settleSwarm(swarm: SwarmStatus | undefined, status: NodeStatus | undefined): SwarmStatus | undefined { + if (!swarm || status === undefined || status === "running" || status === "queued") return swarm + if (!swarm.active && !swarm.counts.running && !swarm.counts.queued) return swarm + const counts = { ...swarm.counts, running: 0, queued: 0, cancelled: swarm.counts.cancelled + swarm.counts.running + swarm.counts.queued } + const agents = swarm.agents.map((entry) => (entry.state === "running" || entry.state === "queued" ? { ...entry, state: "cancelled" as const } : entry)) + return { ...swarm, active: false, counts, agents } +} + /** The card line shows once the swarm did something or degraded; a loaded-but-idle extension is not news. */ export function swarmVisible(swarm: SwarmStatus | undefined): swarm is SwarmStatus { if (!swarm || swarm.mode === "off") return false @@ -320,7 +334,8 @@ export function swarmPills(agents: SwarmAgentState[]) { return { pills, more: Math.max(0, agents.length - PILL_LIMIT) } } -export function swarmLine(swarm: SwarmStatus | undefined) { +export function swarmLine(raw: SwarmStatus | undefined, status?: NodeStatus) { + const swarm = settleSwarm(raw, status) if (!swarmVisible(swarm)) return undefined const counts = swarmCountsText(swarm.counts) || "delegating" const { pills, more } = swarmPills(swarm.agents) @@ -351,11 +366,12 @@ export function stageSnapshot(runtime: { uplift?: UpliftInfo memory?: MemoryInfo swarm?: SwarmStatus + status?: NodeStatus }): StageSnapshot { return { uplift: upliftChip(runtime.uplift)?.aria, memory: memoryChip(runtime.memory)?.aria, - swarm: swarmLine(runtime.swarm)?.aria, + swarm: swarmLine(runtime.swarm, runtime.status)?.aria, } } diff --git a/packages/flow/src/ui/card-stages.tsx b/packages/flow/src/ui/card-stages.tsx index 4fdacbb5c0..5684e32479 100644 --- a/packages/flow/src/ui/card-stages.tsx +++ b/packages/flow/src/ui/card-stages.tsx @@ -2,6 +2,7 @@ import { For, Show, createEffect, createSignal } from "solid-js" import type { MemoryInfo } from "../../shared/memory" import type { SwarmStatus } from "../../shared/swarm" import type { UpliftInfo } from "../../shared/uplift" +import type { NodeStatus } from "../graph/types" import { runtimeOf } from "../state" import { decisionLine, @@ -9,6 +10,7 @@ import { memoryReadText, memoryRow, memoryWriteText, + settleSwarm, stageAnnouncement, stageSnapshot, swarmAgentText, @@ -61,9 +63,9 @@ export function MemoryChip(props: { memory?: MemoryInfo }) { } /** One truncating line: counts, then a pill per agent. */ -export function SwarmLine(props: { swarm?: SwarmStatus }) { +export function SwarmLine(props: { swarm?: SwarmStatus; status?: NodeStatus }) { return ( - + {(line) => (