From 46bbdbe90932fd14fb2d632d4e8d24a0d8590f8e Mon Sep 17 00:00:00 2001 From: Matt Galligan Date: Fri, 5 Jun 2026 14:09:43 -0400 Subject: [PATCH] feat: add top-level thread actions --- .claude/rules/client.md | 3 +- README.md | 12 +- .../0005-lane-authority-capability-ladder.md | 8 +- ...-codex-session-registration-is-explicit.md | 2 +- .../0016-history-goals-and-bounded-watch.md | 12 +- .../0017-progressive-thread-sync-index.md | 2 +- ...018-top-level-thread-actions-and-search.md | 97 ++++++ docs/adrs/README.md | 3 +- docs/development/design.md | 25 +- docs/usage/README.md | 57 +++- skills/dispatch/SKILL.md | 56 ++- skills/dm/SKILL.md | 10 +- src/outfitter/dispatch/client/client.py | 37 +- src/outfitter/dispatch/client/models.py | 36 ++ src/outfitter/dispatch/contracts/context.py | 18 + .../dispatch/contracts/derive_cli.py | 186 +++++++++- .../dispatch/contracts/derive_mcp.py | 2 + src/outfitter/dispatch/contracts/errors.py | 2 +- src/outfitter/dispatch/core/handlers.py | 320 ++++++++++++++++-- src/outfitter/dispatch/core/models.py | 72 +++- src/outfitter/dispatch/core/ops.py | 92 ++++- src/outfitter/dispatch/daemon/supervisor.py | 2 +- tests/client/test_client.py | 39 +++ tests/core/test_examples.py | 2 + tests/core/test_handlers.py | 244 ++++++++++++- tests/daemon/test_control.py | 2 +- tests/fakes.py | 42 +++ tests/integration/test_app_server.py | 31 +- tests/surfaces/test_derive_cli.py | 129 ++++++- tests/surfaces/test_derive_mcp.py | 8 +- tests/surfaces/test_parity.py | 34 ++ 31 files changed, 1482 insertions(+), 103 deletions(-) create mode 100644 docs/adrs/0018-top-level-thread-actions-and-search.md diff --git a/.claude/rules/client.md b/.claude/rules/client.md index 7905e99..42c2edd 100644 --- a/.claude/rules/client.md +++ b/.claude/rules/client.md @@ -14,11 +14,12 @@ Demux the single stream: responses by request `id`, notifications by `threadId`, ## Primitives (typed; Pydantic wire models) -`initialize` → `thread_start/resume/list/read/archive` → `turn_start/steer/interrupt` → `inject_items` → approval responder. Verified gotchas to encode: +`initialize` → `thread_start/resume/list/read/archive/unarchive/name-set/search` → `turn_start/steer/interrupt` → `inject_items` → approval responder. Verified gotchas to encode: - `thread/start.sandbox` is a **string** enum (`read-only`/`workspace-write`/`danger-full-access`); `turn/start.sandboxPolicy` is an **object** (`{type:"readOnly", ...}`). Different encodings — model both. - `turn/steer` requires `expectedTurnId` (from `turn/started`). - `thread/list` results are under `result.data` (not `result.threads`); `useStateDbOnly:true` reads the persisted store. +- `thread/search` is experimental; enable the experimental API capability before using it and keep the wrapper thin. - `thread/resume` of a *persisted* thread yields live event fan-out; pre-persistence it errors `no rollout found`. - Approvals are server→client requests: lane emits `thread/status/changed` `activeFlags:["waitingOnApproval"]`; reply `{id, result:{decision}}` (`accept`/`acceptForSession`/`decline`/`cancel`); server emits `serverRequest/resolved`. File-change approvals carry **no diff** — correlate by `itemId` to the `fileChange` item. - Threads persist by default (`ephemeral:false`). Pass `ephemeral:true` for throwaway/test lanes. diff --git a/README.md b/README.md index 888151c..ed8b9f8 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,13 @@ uv run dispatch daemon log --limit 10 uv run dispatch down ``` -Use owned lanes for writes. Existing desktop Codex threads can be attached, but v0 treats -attached lanes as observe-only: mutating commands such as `send`, `stop`, `lane archive`, -`goal set`, `goal clear`, `lane fork`, `lane rollback`, and `lane compact` are blocked by -ADR-0005. Attach is metadata-only by default; use `dispatch lane sync ` when you want -dispatch to refresh its local indexed view of an attached thread. +Use owned lanes for turn-writing work. Existing desktop Codex threads can be attached as +managed lanes, but ADR-0005 still blocks turn-writing and history-mutating commands such +as `send`, `stop`, `goal set`, `goal clear`, `lane fork`, `lane rollback`, and +`lane compact` on attached lanes. Metadata/lifecycle actions (`rename`, `archive`, +`restore`) can target managed lanes or raw unmanaged Codex thread ids, and `search` can +span both. Attach is metadata-only by default; use `dispatch lane sync ` when you +want dispatch to refresh its local indexed view of an attached thread. For the operator guide, CLI/MCP examples, triggers, and plugin setup, start at [`docs/usage/README.md`](docs/usage/README.md). diff --git a/docs/adrs/0005-lane-authority-capability-ladder.md b/docs/adrs/0005-lane-authority-capability-ladder.md index d3879c1..6efddcb 100644 --- a/docs/adrs/0005-lane-authority-capability-ladder.md +++ b/docs/adrs/0005-lane-authority-capability-ladder.md @@ -10,7 +10,7 @@ owners: ['[galligan](https://github.com/galligan)'] # ADR-0005: Lane Authority Capability Ladder -> Accepted (2026-06-03) after the Phase-1 cross-process spike. The spike did **not** clear attached-lane writes — it confirmed the observe-only default and revealed that cross-process observation is not even live. See "Phase-1 spike outcome" below. The gated write rungs remain locked for v0. +> Accepted (2026-06-03) after the Phase-1 cross-process spike. The spike did **not** clear attached-lane turn writes — it confirmed the write-locked default and revealed that cross-process observation is not live. See "Phase-1 spike outcome" below. The gated turn-write rungs remain locked for v0. ## Context @@ -21,7 +21,7 @@ dispatch drives both lanes it **owns** (created via `open`) and lanes it **attac Authority over a lane is a ladder, not a flag: - **Owned lanes** (dispatch created them): full read/write, always. -- **Attached lanes** (existing desktop threads): **observe-only by default** — read metadata, sync a local index, read history; no `send`/`steer`/`brief`/`interrupt`. +- **Attached lanes** (existing desktop threads): turn-writing and history-mutating ops are blocked by default — read metadata, sync a local index, read history, and allow explicit metadata/lifecycle actions (`rename`, `archive`, `restore`); no `send`/`steer`/`brief`/`interrupt`, `goal set/clear`, `fork`, `rollback`, or `compact`. - **idle-only-write** and **full-write** on attached lanes are unlocked only when **(a)** the slice-0 cross-process spike shows it is safe, **and (b)** the user explicitly opts in (per-lane or global). ## Assumptions (must hold; verify before relying) @@ -44,7 +44,7 @@ Two `codex app-server` processes shared one isolated `CODEX_HOME` (modelling our - **Live fan-out does NOT cross processes:** while A ran a turn, B (resumed) received **zero** live events. Live event fan-out is intra-process only (one app-server process). The spike-04 "resume = live co-presence" finding holds only for multiple connections to the *same* server process — which is exactly dispatch's own topology (ADR-0002), not the desktop-vs-daemon case. - **Concurrent turns are uncoordinated:** A and B each ran a turn on the shared thread with no error returned, but there is no cross-process interlock (dispatch's advisory lock is dispatch-local and cannot gate the desktop app), so "no error" is not "safe." -**Decision:** keep attached lanes **observe-only** for v0. Observation is limited to metadata reads, explicit sync, history read, and periodic re-read (no live cross-process stream). ADR-0017 makes default attach metadata-only instead of `thread/resume`-based. The idle-only-write and full-write rungs stay locked; unlocking them needs a real cross-process interlock, which Codex does not expose today. This is the safe default the ladder already proposed — the spike confirms rather than relaxes it. +**Decision:** keep attached lanes locked for turn-writing and history-mutating ops in v0. Observation is limited to metadata reads, explicit sync, history read, and periodic re-read (no live cross-process stream). ADR-0018 permits explicit metadata/lifecycle actions (`rename`, `archive`, `restore`) because they do not start turns, steer turns, or mutate turn history. ADR-0017 makes default attach metadata-only instead of `thread/resume`-based. The idle-only-write and full-write rungs stay locked; unlocking them needs a real cross-process interlock, which Codex does not expose today. This is the safe default the ladder already proposed — the spike confirms rather than relaxes it. ## Alternatives considered @@ -53,4 +53,4 @@ Two `codex app-server` processes shared one isolated `CODEX_HOME` (modelling our ## References -- ADR-0002 (Single Daemon over One App Server); ADR-0017 (Progressive Thread Sync Index); `docs/research/app-server-verification.md` (resume fan-out, cross-process untested); `PLAN.md` Phase-1 slice-0 spike. +- ADR-0002 (Single Daemon over One App Server); ADR-0017 (Progressive Thread Sync Index); ADR-0018 (Top-Level Thread Actions and Search); `docs/research/app-server-verification.md` (resume fan-out, cross-process untested); `PLAN.md` Phase-1 slice-0 spike. diff --git a/docs/adrs/0011-codex-session-registration-is-explicit.md b/docs/adrs/0011-codex-session-registration-is-explicit.md index 1b161a4..80d19c8 100644 --- a/docs/adrs/0011-codex-session-registration-is-explicit.md +++ b/docs/adrs/0011-codex-session-registration-is-explicit.md @@ -12,7 +12,7 @@ owners: ['[galligan](https://github.com/galligan)'] ## Context -dispatch can discover persisted Codex sessions and attach them as lanes, but ADR-0005 keeps attached lanes observe-only by default because desktop Codex and dispatch run separate app-server processes over shared state. Automatically "picking up" every new Codex session would surprise users who do not want all agents visible to dispatch, a mesh peer, an MCP client, or automation triggers. +dispatch can discover persisted Codex sessions and attach them as lanes, but ADR-0005 keeps attached lanes blocked for turn-writing by default because desktop Codex and dispatch run separate app-server processes over shared state. Automatically "picking up" every new Codex session would surprise users who do not want all agents visible to dispatch, a mesh peer, an MCP client, or automation triggers. Some users will want the opposite: a smooth path where sessions created in Codex become known to dispatch without manual `attach`. Codex hooks on session/thread start could provide that path by registering a session intentionally at creation time. diff --git a/docs/adrs/0016-history-goals-and-bounded-watch.md b/docs/adrs/0016-history-goals-and-bounded-watch.md index e08854a..7a01cbb 100644 --- a/docs/adrs/0016-history-goals-and-bounded-watch.md +++ b/docs/adrs/0016-history-goals-and-bounded-watch.md @@ -40,7 +40,7 @@ Treat `watch` as a bounded sample, not a streaming subscription. A durable live belongs in a later protocol change that can push events over the control socket. Keep mutating history/goal operations locked to owned lanes. Attached lanes remain -observe-only until cross-process semantics are verified. +turn-write locked until cross-process semantics are verified. ## Consequences @@ -48,8 +48,8 @@ observe-only until cross-process semantics are verified. - Agents can harvest history, inspect goals, and control long-running lanes without leaving the contract-derived CLI/MCP architecture. -- The implementation uses stable App Server methods and avoids experimental - `thread/turns/list` or `thread/search`. +- The implementation uses stable App Server methods for transcript, goal, and history + controls. ADR-0018 separately allows broad search on experimental `thread/search`. - The watch surface is honest about current transport limits. ### Negative @@ -65,5 +65,7 @@ observe-only until cross-process semantics are verified. and existing operator docs made `show` the summary command. - **Expose a fake infinite `tail` over request/response JSONL** — rejected: it would be misleading and fragile. -- **Use experimental `thread/turns/list` and `thread/search` now** — rejected: stable - `thread/read(includeTurns:true)` is enough for the first history surface. +- **Use experimental `thread/turns/list` for transcript now** — rejected: stable + `thread/read(includeTurns:true)` is enough for the first history surface. ADR-0018 later + accepts experimental `thread/search` only for broad search, where stable App Server + primitives do not provide an equivalent. diff --git a/docs/adrs/0017-progressive-thread-sync-index.md b/docs/adrs/0017-progressive-thread-sync-index.md index 48a48a7..383dac5 100644 --- a/docs/adrs/0017-progressive-thread-sync-index.md +++ b/docs/adrs/0017-progressive-thread-sync-index.md @@ -32,7 +32,7 @@ Attach is metadata-only by default: - `dispatch lane attach ` verifies the id with `thread/read(includeTurns:false)`. -- It registers an observe-only attached lane and stores metadata sync state. +- It registers a turn-write locked attached lane and stores metadata sync state. - It does not call `thread/resume`, load turn history, or grant write authority. Progressive sync is explicit: diff --git a/docs/adrs/0018-top-level-thread-actions-and-search.md b/docs/adrs/0018-top-level-thread-actions-and-search.md new file mode 100644 index 0000000..a1889fb --- /dev/null +++ b/docs/adrs/0018-top-level-thread-actions-and-search.md @@ -0,0 +1,97 @@ +--- +id: 0018 +slug: top-level-thread-actions-and-search +title: Top-Level Thread Actions and Search +status: accepted +created: 2026-06-05 +updated: 2026-06-05 +owners: ['[galligan](https://github.com/galligan)'] +--- + +# ADR-0018: Top-Level Thread Actions and Search + +## Context + +dispatch originally required a Codex thread to be registered as a lane before most +operator workflows felt natural. That made the first-run experience clunky: a user could +see existing Codex sessions through `lane list --unmanaged`, but still had to attach before +basic lifecycle cleanup or targeted inspection. + +At the same time, ADR-0005 correctly blocks turn-writing and history-mutating operations +on attached lanes because dispatch and the desktop app do not share a cross-process write +interlock. We need better thread ergonomics without weakening that authority boundary. + +The App Server exposes stable metadata/lifecycle methods (`thread/name/set`, +`thread/archive`, `thread/unarchive`) and an experimental broad search method +(`thread/search`). The experimental search method can search persisted threads, but it +does not provide all dispatch-facing filters directly. + +## Decision + +Use three explicit states: + +- **Managed**: a thread registered in dispatch's registry, either owned or attached. +- **Unmanaged**: a persisted Codex thread visible to App Server but not registered in + dispatch. +- **Synced**: a managed lane whose local dispatch index has been refreshed. Sync is + separate from management and separate from App Server thread lifecycle. + +Expose `rename`, `archive`, `restore`, and `search` at the top level, while keeping lane +group variants for lane-shaped workflows: + +- `dispatch rename ` and `dispatch lane rename ` +- `dispatch archive ` and `dispatch lane archive ` +- `dispatch restore ` and `dispatch lane restore ` +- `dispatch search ` and `dispatch lane search ` + +Targets may be a managed lane id, a managed `@handle`, or a raw unmanaged Codex thread id. +An unresolved `@handle` is a missing lane, not a raw thread id fallback. Raw thread ids keep +the first-run path available without silently reinterpreting human handles. + +`restore` only calls `thread/unarchive`; it must not resume the thread, start a turn, or +drain queued work. + +Broad `search` uses experimental App Server `thread/search`, then applies dispatch-side +filters for managed/unmanaged state, repo/directory containment, and date ranges. Focused +lane search uses `thread/read(includeTurns:true)` and a local substring scan because the +App Server search schema does not expose a thread-id filter. + +## Consequences + +### Positive + +- Users can clean up and inspect existing Codex threads before deciding whether to attach. +- The managed/unmanaged/synced vocabulary matches the real state model and avoids implying + that sync grants authority. +- Attached lanes remain protected from turn-writing and history-mutating operations while + still supporting explicit metadata/lifecycle actions. +- CLI and MCP continue to derive from the same ops; top-level commands are ergonomic routes, + not separate behavior. + +### Negative + +- Broad search depends on an experimental App Server method. It must stay documented as + experimental and covered by schema/client tests. +- Repo, directory, date, and managed/unmanaged filters are dispatch-side filters, so + `--max-scan` can bound results before every possible match is examined. +- Unmanaged raw-id actions rely on App Server errors for nonexistent raw ids. + +## Alternatives Considered + +- **Require attach before rename/archive/restore/search** — rejected: it preserves the old + friction and makes first-run cleanup unnecessarily indirect. +- **Treat sync as attach/hydrate/management** — rejected: sync is only an index refresh and + should not imply write authority or ownership. +- **Use `thread/search` for lane-focused search too** — rejected: the current experimental + schema does not provide a thread-id filter, so local transcript scan is more precise. +- **Build a full local transcript database first** — rejected for this slice: progressive + sync already captures compact local facts, and broad search can start from App Server + search without an ingestion-heavy first-run path. + +## References + +- ADR-0005 (Lane Authority Capability Ladder) +- ADR-0016 (History, Goals, and Bounded Watch) +- ADR-0017 (Progressive Thread Sync Index) +- `docs/research/app-server-verification.md` (`thread/name/set`, `thread/archive`, + `thread/unarchive`, experimental `thread/search`) diff --git a/docs/adrs/README.md b/docs/adrs/README.md index 820675f..a406233 100644 --- a/docs/adrs/README.md +++ b/docs/adrs/README.md @@ -13,7 +13,7 @@ Files are `NNNN-slug.md`. Copy [`template.md`](template.md) to start one. Keep t | [0002](0002-single-daemon-over-one-app-server.md) | Single Daemon over One App Server | Accepted | | [0003](0003-own-scheduler-not-codex-automations.md) | Own Scheduler, Not Codex Automations | Accepted | | 0004 | Single-Sourced Agent Docs (`.claude/rules` ↔ `AGENTS.md` symlinks) | Accepted — see [`.claude/rules/agent-docs.md`](../../.claude/rules/agent-docs.md) | -| [0005](0005-lane-authority-capability-ladder.md) | Lane Authority Capability Ladder | Accepted — Phase-1 spike confirmed attached=observe-only | +| [0005](0005-lane-authority-capability-ladder.md) | Lane Authority Capability Ladder | Accepted — Phase-1 spike keeps attached lanes turn-write locked | | [0006](0006-handler-context-and-di.md) | Handler Context and Dependency Injection | Accepted | | [0007](0007-normalized-internal-lane-events.md) | Normalized Internal LaneEvent Vocabulary | Accepted | | [0008](0008-control-socket-protocol.md) | Control-Socket Protocol — JSON-RPC-lite over JSONL | Accepted | @@ -26,3 +26,4 @@ Files are `NNNN-slug.md`. Copy [`template.md`](template.md) to start one. Keep t | [0015](0015-new-command-config-presets-and-name-prefixes.md) | New Command, Config Presets, and Name Prefixes | Proposed | | [0016](0016-history-goals-and-bounded-watch.md) | History, Goals, and Bounded Watch | Accepted | | [0017](0017-progressive-thread-sync-index.md) | Progressive Thread Sync Index | Accepted | +| [0018](0018-top-level-thread-actions-and-search.md) | Top-Level Thread Actions and Search | Accepted | diff --git a/docs/development/design.md b/docs/development/design.md index 80b41e2..36587f0 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -11,7 +11,7 @@ Status: approved design, implemented through v0. Companion research (verified ag ## Goals / non-goals -Goals (v1): a single daemon that owns one Codex app-server and drives many lanes; a typed CLI and an MCP server, both derived from one contract set; time + event triggers; durable registry of lanes and triggers; full read/write on self-spawned owned lanes. Existing desktop lanes can be attached, but v0 keeps them observe-only per ADR-0005. +Goals (v1): a single daemon that owns one Codex app-server and drives many lanes; a typed CLI and an MCP server, both derived from one contract set; time + event triggers; durable registry of lanes and triggers; full read/write on self-spawned owned lanes. Existing desktop lanes can be attached as managed lanes. They remain blocked for turn-writing and history-mutating ops per ADR-0005, while explicit metadata/lifecycle actions and search can target managed or unmanaged Codex threads per ADR-0018. Non-goals (v1): Claude/crew backend; conditional triggers (seam only); dashboard/TUI; full approval policy engine; multi-user; remote-control surface (planned v2). @@ -43,7 +43,7 @@ contracts ──────►├── MCP (mcp SDK) ─┤──► daemon co ## Module layout (clean layers; `client` + `registry` importable without the daemon) `src/outfitter/dispatch/` (PEP 420 namespace — no `__init__.py` at the `outfitter/` level): -- `client/` — typed App Server client. Spawns app-server, stdio JSONL, message router, async event streams. Primitives: initialize · thread start/resume/list/read/archive · turn start/steer/interrupt · inject_items · approval responder. Pydantic models for wire messages. Importable standalone. +- `client/` — typed App Server client. Spawns app-server, stdio JSONL, message router, async event streams. Primitives: initialize · thread start/resume/list/read/archive/unarchive/search/name-set · turn start/steer/interrupt · inject_items · approval responder. Pydantic models for wire messages. Importable standalone. - `contracts/` — the op definitions (one per operation) + the registry + projection functions (`derive_cli`, `derive_mcp`, `derive_remote`) + error taxonomy. - `registry/` — SQLite (aiosqlite) store of lanes, triggers, and an actions audit log. Importable standalone. - `core/` — scheduler (time triggers), reactor (event triggers), trigger model + guards, and the handlers that fulfill the contracts. @@ -77,9 +77,11 @@ Projections (pure functions over the registry, mirroring Trails' `derive* → cr - Lane reads/discovery: `lane get ` · `lane status ` · `lane list` · `lane list --unmanaged` · `lane sync ` · `lane tail ` · `lane tail --follow` -- Lane management/history: `lane attach [--sync]` · `lane rename ` · +- Lane management/history: `rename ` · `archive ` · + `restore ` · `search ` with lane/repo/directory/date filters · + `lane attach [--sync]` · `lane rename ` · `lane fork ` · `lane rollback ` · `lane compact ` · - `lane archive ` + `lane archive ` · `lane restore ` · `lane search ` - Sending: `send "…"` with `--mode send|steer|queue|interject|context` and equivalent mutually exclusive `--steer`, `--queue`, `--interject`, `--context`; `stop ` / `stop --lane ` is cancel-only. @@ -98,7 +100,7 @@ boundary rather than forced to be one tool per op. The noun for a managed thread | --- | --- | --- | | `open` | `thread/start` (then register) | `sandbox` is a STRING enum (`read-only`/`workspace-write`/`danger-full-access`); persists by default (`ephemeral:false`) → spawned lanes show in desktop app, matching the `→ @project:name` convention. | | `new` | `thread/start` + `thread/name/set` + optional `turn/start` | Applies `.dispatch/config.toml` defaults/presets, name prefixes, verified session/turn options, and optional initial payload. | -| `attach` | `thread/read(includeTurns:false)` (+ register) | Metadata-only by default: verifies the thread id, registers an observe-only attached lane, and stores sync state without loading turn history. `--sync` runs a quick local index refresh after registration. | +| `attach` | `thread/read(includeTurns:false)` (+ register) | Metadata-only by default: verifies the thread id, registers a turn-write locked attached lane, and stores sync state without loading turn history. `--sync` runs a quick local index refresh after registration. | | `sync` (`lane sync`) | `thread/read(includeTurns:false)` + bounded local JSONL parsing | Refreshes dispatch's index/cache for a lane: source file identity, sync state, latest event timestamp, latest turn id, preview, and selected metadata. Does not copy transcripts wholesale or grant attached-lane write authority. | | `send` (`mode=send`) | `turn/start` | Delivers a message the lane processes + answers. The DM/`send_message_to_thread` equivalent. `sandboxPolicy` here is an OBJECT (`{type:"readOnly"}`) — different encoding than `thread/start.sandbox`. | | `send` (`mode=queue`) | registry queue + later `turn/start` | Persists local queued delivery and starts one queued turn when the lane becomes idle. | @@ -106,7 +108,10 @@ boundary rather than forced to be one tool per op. The noun for a managed thread | `send` (`mode=context`) | `thread/inject_items` | Silent model-visible context injection (Responses-API items); no turn runs. Trigger actions still call this lower-level behavior `brief`. | | `send` (`mode=interject`) | `turn/interrupt` + `turn/start` | Requires an active turn id, cancels that turn, then starts replacement work. | | `stop` | `turn/interrupt` | Requires an active turn id and cancels the active turn without replacement text. | -| `archive` | `thread/archive` | Reversible via `thread/unarchive` for persisted lanes. If App Server reports `no rollout found` for an owned no-rollout lane, dispatch archives the local registry entry so throwaway lanes can be cleaned up. | +| `lane-rename` (`rename`, `lane rename`) | `thread/name/set` (+ registry update when managed) | Accepts a managed lane id/handle or a raw unmanaged Codex thread id. Unresolved `@handles` fail as missing lanes rather than falling through as raw ids. | +| `archive` (`archive`, `lane archive`) | `thread/archive` | Accepts managed lanes or unmanaged raw thread ids. If App Server reports `no rollout found` for an owned no-rollout lane, dispatch archives the local registry entry so throwaway lanes can be cleaned up. | +| `restore` (`restore`, `lane restore`) | `thread/unarchive` | Restores the archived Codex thread only; does not resume or start a new turn. | +| `search` (`search`, `lane search`) | experimental `thread/search` for broad search; `thread/read(includeTurns:true)` for one-lane search | Broad search uses App Server search plus dispatch-side managed/unmanaged, repo/directory, and date filters. Lane-focused search reads one transcript and scans locally because App Server search has no thread-id filter. | | `roster` (`lane list`) | `thread/list` + registry + status | List results are under `result.data` (NOT `result.threads`); `useStateDbOnly:true` reads the persisted store. | | `discover` (`lane list --unmanaged`) | `thread/list` state DB only | Lists persisted Codex sessions that could be attached; it does not resume or register them. | | `show` (`lane get/status`) | registry + optional `thread/read(includeTurns:true)` | Compact lane summary; optional transcript convenience. | @@ -130,9 +135,11 @@ A trigger binds **when → action → lane**, stored in the registry: The scheduler is **our own** (asyncio): a time wheel for time triggers + the reactor consuming the event stream for event triggers. We do not use Codex's filesystem automations (they're daemon-registered, not protocol; live pickup unconfirmed) — owning the scheduler gives full control and is why this approach was chosen. -## Lanes: owned write, attached observe-only +## Lanes: owned write, attached managed, unmanaged raw threads -The daemon drives threads it spawns (`new`, backed by the lower-level `open` op) with full read/write. Existing desktop threads can be registered with `attach`, but they are **observe-only in v0**. The Phase-1 cross-process spike confirmed that a second app-server process can discover and read persisted history, but live event fan-out does not cross processes and concurrent turns are uncoordinated. Dispatch's advisory lock is dispatch-local; it cannot gate the desktop app. ADR-0005 keeps attached-lane writes locked until there is a real cross-process interlock and an explicit user opt-in. +The daemon drives threads it spawns (`new`, backed by the lower-level `open` op) with full read/write. Existing desktop threads can be registered with `attach`, becoming **managed attached lanes**. The Phase-1 cross-process spike confirmed that a second app-server process can discover and read persisted history, but live event fan-out does not cross processes and concurrent turns are uncoordinated. Dispatch's advisory lock is dispatch-local; it cannot gate the desktop app. + +ADR-0005 keeps turn-writing and history-mutating ops locked on attached lanes until there is a real cross-process interlock and an explicit user opt-in. ADR-0018 carves out explicit metadata/lifecycle actions (`rename`, `archive`, `restore`) and search because they do not start turns, steer turns, or mutate turn history. **Unmanaged** means a persisted Codex thread visible to App Server but not registered in dispatch; sync remains a separate managed-lane index refresh. ## Approvals (v1 minimal) @@ -181,6 +188,6 @@ The client supports the full responder loop. v1 surfaces `waiting_on_approval` a ## Open risks / questions -- **Cross-process contention** (dispatch vs desktop app-server on one thread) — resolved for v0 by ADR-0005: attached lanes are observe-only. +- **Cross-process contention** (dispatch vs desktop app-server on one thread) — resolved for v0 by ADR-0005/0018: attached lanes are turn-write locked, while metadata/lifecycle actions are explicit. - **MCP transport** — stdio first; SSE/streamable-HTTP later (mirrors Codex/Trails MCP status). - **App-server version drift** — pin the binary; the Python SDK pins an older CLI (0.132) than local (0.136), so we drive the binary directly, not via the SDK. diff --git a/docs/usage/README.md b/docs/usage/README.md index 6b87171..af20566 100644 --- a/docs/usage/README.md +++ b/docs/usage/README.md @@ -197,7 +197,7 @@ Use `send --context` for silent context injection. It adds model-visible context starting a turn: ```bash -uv run dispatch send @docs-review "Context: attached lanes are observe-only in v0." --context +uv run dispatch send @docs-review "Context: attached lanes are not turn-writable in v0." --context ``` Use `send --steer` only while the lane has an active turn: @@ -273,6 +273,45 @@ uv run dispatch lane compact @docs-review `rollback` only truncates persisted App Server history. It does not revert local files. Treat it as a conversation-history operation, not a source-control undo. +## Thread Actions And Search + +`rename`, `archive`, and `restore` are top-level thread actions. They accept a managed +lane id, a managed `@handle`, or a raw unmanaged Codex thread id: + +```bash +uv run dispatch rename @docs-review docs-review-final +uv run dispatch archive @docs-review +uv run dispatch restore +``` + +`restore` unarchives the thread only; it does not resume the thread or start a new turn. +Use the `lane` group when you want the same actions to read as lane management: + +```bash +uv run dispatch lane rename @docs-review docs-review-final +uv run dispatch lane archive @docs-review +uv run dispatch lane restore @docs-review +``` + +Use `search` to search Codex thread history without first attaching every thread: + +```bash +uv run dispatch search "schema drift" +uv run dispatch search "schema drift" --managed +uv run dispatch search "schema drift" --unmanaged +uv run dispatch search "schema drift" --lane @docs-review +uv run dispatch search "schema drift" --repo . +uv run dispatch search "schema drift" --dir /path/to/project +uv run dispatch search "schema drift" --since 2026-06-01 --until 2026-06-05 +uv run dispatch lane search @docs-review "schema drift" +``` + +Broad search uses the App Server experimental `thread/search` primitive, then applies +dispatch-side filters for managed/unmanaged state, repo/directory, and date bounds. Lane +focused search reads that one thread with `thread/read(includeTurns:true)` and performs a +local substring scan. Date bounds accept ISO dates or datetimes and default to filtering +`updated_at`; use `--date-field created_at` when creation time matters. + ## Discover Sessions `lane list` lists the lanes dispatch already manages. `lane list --unmanaged` is the other @@ -292,8 +331,10 @@ uv run dispatch lane attach uv run dispatch lane attach --sync ``` -Keep the two straight: `lane list --unmanaged` shows attachable Codex sessions (not yet -lanes); `lane list` shows managed lanes (owned or already attached). +Keep the two straight: `lane list --unmanaged` shows unmanaged Codex sessions that are not +registered in dispatch; `lane list` shows managed lanes (owned or already attached). Sync is +separate from both: `lane sync` refreshes dispatch's local index for a managed lane, but it +does not change ownership or write authority. ## Attached Lanes @@ -304,10 +345,12 @@ uv run dispatch lane attach uv run dispatch lane sync ``` -Attached lanes are observe-only in v0. Dispatch can register and inspect them, but it must -not write to them because the desktop app uses a separate app-server process and there is no -cross-process write interlock. ADR-0005 is the authoritative decision: -[`docs/adrs/0005-lane-authority-capability-ladder.md`](../adrs/0005-lane-authority-capability-ladder.md). +Attached lanes allow observation, sync, and explicit metadata/lifecycle actions such as +`rename`, `archive`, and `restore`. Dispatch still must not write turns or mutate history on +attached lanes because the desktop app uses a separate app-server process and there is no +cross-process write interlock. ADR-0005 and ADR-0018 are the authoritative decisions: +[`docs/adrs/0005-lane-authority-capability-ladder.md`](../adrs/0005-lane-authority-capability-ladder.md) +and [`docs/adrs/0018-top-level-thread-actions-and-search.md`](../adrs/0018-top-level-thread-actions-and-search.md). Attach is compact by default: it verifies the thread with App Server `thread/read(includeTurns:false)`, registers the lane, and stores metadata sync state. It diff --git a/skills/dispatch/SKILL.md b/skills/dispatch/SKILL.md index 4e7dbe9..eafecd7 100644 --- a/skills/dispatch/SKILL.md +++ b/skills/dispatch/SKILL.md @@ -27,10 +27,11 @@ The current operator grammar is: - health: `doctor` - daemon process: `up`, `down` - daemon reads: `daemon status`, `daemon log` -- create/send: `new`, `send`, `stop` +- create/send/search: `new`, `send`, `stop`, `search` +- top-level thread actions: `rename`, `archive`, `restore` - lanes: `lane get`, `lane status`, `lane list`, `lane list --unmanaged`, - `lane attach`, `lane sync`, `lane rename`, `lane tail`, `lane fork`, `lane rollback`, - `lane compact`, `lane archive` + `lane attach`, `lane sync`, `lane rename`, `lane search`, `lane tail`, `lane fork`, + `lane rollback`, `lane compact`, `lane archive`, `lane restore` - goals: `goal status`, `goal set`, `goal clear` - triggers: `trigger add`, `trigger list`, `trigger rm`, `trigger pause`, `trigger resume` @@ -82,11 +83,14 @@ uv run dispatch lane attach uv run dispatch lane attach --sync ``` -Attached lanes are observe-only in v0. Do not try mutating commands such as -`send`, `stop`, `lane archive`, `goal set`, `goal clear`, `lane fork`, -`lane rollback`, or `lane compact` on attached lanes. ADR-0005 keeps those -writes locked because desktop Codex and dispatch run separate app-server -processes and there is no cross-process write interlock. +Attached lanes are managed by dispatch but are not turn-writable in v0. Do not +try turn-writing or history-mutating commands such as `send`, `stop`, +`goal set`, `goal clear`, `lane fork`, `lane rollback`, or `lane compact` on +attached lanes. Explicit metadata/lifecycle commands (`rename`, `archive`, +`restore`) are allowed because they do not start turns or mutate turn history. +ADR-0005 and ADR-0018 keep this boundary locked because desktop Codex and +dispatch run separate app-server processes and there is no cross-process write +interlock. Attach is compact by default: it verifies the thread with `thread/read(includeTurns:false)`, registers metadata, and does not resume turn @@ -106,8 +110,9 @@ lanes. ## Discover Sessions `lane list` shows lanes dispatch already manages. `lane list --unmanaged` lists -persisted Codex sessions you could attach through App Server `thread/list` in -state-db-only mode. It is read-only and does not resume or register anything: +persisted Codex sessions that are not registered in dispatch. It uses App Server +`thread/list` in state-db-only mode. It is read-only and does not resume or +register anything: ```bash uv run dispatch lane list @@ -116,6 +121,37 @@ uv run dispatch lane list --unmanaged --limit 20 Use a discovered session `id` with `lane attach `. +## Search And Thread Actions + +Use top-level actions when you want to work with either managed lanes or raw +unmanaged Codex thread ids: + +```bash +uv run dispatch rename @my-lane my-lane-final +uv run dispatch archive +uv run dispatch restore @my-lane +``` + +`restore` only unarchives; it does not resume or start a turn. + +Use `search` before attaching when you need to find the right existing thread: + +```bash +uv run dispatch search "schema drift" +uv run dispatch search "schema drift" --managed +uv run dispatch search "schema drift" --unmanaged +uv run dispatch search "schema drift" --lane @my-lane +uv run dispatch search "schema drift" --repo . +uv run dispatch search "schema drift" --dir /path/to/project +uv run dispatch search "schema drift" --since 2026-06-01 --until 2026-06-05 +uv run dispatch lane search @my-lane "schema drift" +``` + +Broad search uses experimental App Server `thread/search` plus dispatch-side +filters. Lane-focused search reads one thread transcript and scans locally. +Sync is separate: `lane sync` refreshes dispatch's local index for a managed +lane, but it does not attach unmanaged sessions or grant write authority. + ## Message Verbs `send` is the primary way to put work or context into a lane: diff --git a/skills/dm/SKILL.md b/skills/dm/SKILL.md index 8c52794..a76be11 100644 --- a/skills/dm/SKILL.md +++ b/skills/dm/SKILL.md @@ -29,12 +29,12 @@ If the environment is new, run `uv run dispatch doctor` once before messaging. Fix PATH, Codex auth, stale daemon files, registry, or plugin asset warnings before assuming a DM failure is about the target lane. -The target should be an owned dispatch lane. Attached lanes are observe-only in -v0, so dispatch will reject write verbs against them. +The target should be an owned dispatch lane. Attached lanes are not +turn-writable in v0, so dispatch will reject DM/send verbs against them. -If the user wants to message an existing desktop Codex thread, attach it only -for observation unless they explicitly choose a different authority model after -reading ADR-0005. +If the user wants to message an existing desktop Codex thread, attach/sync it +only for observation unless they explicitly choose a different authority model +after reading ADR-0005. ## Handles And URIs diff --git a/src/outfitter/dispatch/client/client.py b/src/outfitter/dispatch/client/client.py index 7ea4729..35df116 100644 --- a/src/outfitter/dispatch/client/client.py +++ b/src/outfitter/dispatch/client/client.py @@ -29,6 +29,7 @@ Personality, ReasoningSummary, SandboxPolicy, + SortDirection, TextInput, ThreadArchiveParams, ThreadCompactStartParams, @@ -48,8 +49,13 @@ ThreadResumeParams, ThreadRollbackParams, ThreadSandbox, + ThreadSearchParams, + ThreadSearchResult, ThreadSetNameParams, + ThreadSortKey, + ThreadSourceKind, ThreadStartParams, + ThreadUnarchiveParams, TurnInterruptParams, TurnStartParams, TurnSteerParams, @@ -151,7 +157,7 @@ async def initialize( ) -> InitializeResult: params = InitializeParams( client_info=client_info, - capabilities=capabilities if capabilities is not None else {"experimentalApi": False}, + capabilities=capabilities if capabilities is not None else {"experimentalApi": True}, ) result = await self._request("initialize", _dump(params)) await self._notify("initialized", {}) @@ -241,11 +247,40 @@ async def thread_read(self, thread_id: str, include_turns: bool = False) -> dict async def thread_archive(self, thread_id: str) -> None: await self._request("thread/archive", _dump(ThreadArchiveParams(thread_id=thread_id))) + async def thread_unarchive(self, thread_id: str) -> ThreadInfo: + result = await self._request( + "thread/unarchive", _dump(ThreadUnarchiveParams(thread_id=thread_id)) + ) + return ThreadResult.model_validate(result).thread + async def thread_set_name(self, thread_id: str, name: str) -> None: await self._request( "thread/name/set", _dump(ThreadSetNameParams(thread_id=thread_id, name=name)) ) + async def thread_search( + self, + search_term: str, + *, + archived: bool | None = None, + cursor: str | None = None, + limit: int | None = None, + sort_direction: SortDirection | None = None, + sort_key: ThreadSortKey | None = None, + source_kinds: list[ThreadSourceKind] | None = None, + ) -> ThreadSearchResult: + params = ThreadSearchParams( + search_term=search_term, + archived=archived, + cursor=cursor, + limit=limit, + sort_direction=sort_direction, + sort_key=sort_key, + source_kinds=source_kinds, + ) + result = await self._request("thread/search", _dump(params)) + return ThreadSearchResult.model_validate(result) + async def thread_rollback(self, thread_id: str, num_turns: int) -> ThreadInfo: result = await self._request( "thread/rollback", _dump(ThreadRollbackParams(thread_id=thread_id, num_turns=num_turns)) diff --git a/src/outfitter/dispatch/client/models.py b/src/outfitter/dispatch/client/models.py index c09ccc2..ea5adc4 100644 --- a/src/outfitter/dispatch/client/models.py +++ b/src/outfitter/dispatch/client/models.py @@ -27,6 +27,20 @@ ReasoningSummary = Literal["auto", "concise", "detailed", "none"] Personality = Literal["none", "friendly", "pragmatic"] Decision = Literal["accept", "acceptForSession", "decline", "cancel"] +SortDirection = Literal["asc", "desc"] +ThreadSortKey = Literal["created_at", "updated_at"] +ThreadSourceKind = Literal[ + "cli", + "vscode", + "exec", + "appServer", + "subAgent", + "subAgentReview", + "subAgentCompact", + "subAgentThreadSpawn", + "subAgentOther", + "unknown", +] ThreadGoalStatus = Literal[ "active", "paused", "blocked", "usageLimited", "budgetLimited", "complete" ] @@ -100,6 +114,7 @@ class ThreadInfo(WireModel): source: str | None = None thread_source: str | None = None model_provider: str | None = None + created_at: int | None = None updated_at: int | None = None turns: list[dict[str, object]] = Field(default_factory=list) @@ -163,6 +178,16 @@ class ThreadUnarchiveParams(WireModel): thread_id: str +class ThreadSearchParams(WireModel): + search_term: str + archived: bool | None = None + cursor: str | None = None + limit: int | None = None + sort_direction: SortDirection | None = None + sort_key: ThreadSortKey | None = None + source_kinds: list[ThreadSourceKind] | None = None + + class ThreadRollbackParams(WireModel): thread_id: str num_turns: int @@ -211,6 +236,17 @@ class ThreadListResult(WireModel): next_cursor: str | None = None +class ThreadSearchMatch(WireModel): + snippet: str + thread: ThreadInfo + + +class ThreadSearchResult(WireModel): + data: list[ThreadSearchMatch] = [] + next_cursor: str | None = None + backwards_cursor: str | None = None + + class ThreadGoalResult(WireModel): goal: ThreadGoal diff --git a/src/outfitter/dispatch/contracts/context.py b/src/outfitter/dispatch/contracts/context.py index c6c6f13..b22e7c3 100644 --- a/src/outfitter/dispatch/contracts/context.py +++ b/src/outfitter/dispatch/contracts/context.py @@ -29,10 +29,14 @@ Personality, ReasoningSummary, SandboxPolicy, + SortDirection, ThreadGoal, ThreadGoalStatus, ThreadInfo, ThreadSandbox, + ThreadSearchResult, + ThreadSortKey, + ThreadSourceKind, ) if TYPE_CHECKING: @@ -85,8 +89,22 @@ async def thread_read( async def thread_archive(self, thread_id: str) -> None: ... + async def thread_unarchive(self, thread_id: str) -> ThreadInfo: ... + async def thread_set_name(self, thread_id: str, name: str) -> None: ... + async def thread_search( + self, + search_term: str, + *, + archived: bool | None = None, + cursor: str | None = None, + limit: int | None = None, + sort_direction: SortDirection | None = None, + sort_key: ThreadSortKey | None = None, + source_kinds: list[ThreadSourceKind] | None = None, + ) -> ThreadSearchResult: ... + async def thread_rollback(self, thread_id: str, num_turns: int) -> ThreadInfo: ... async def thread_compact_start(self, thread_id: str) -> None: ... diff --git a/src/outfitter/dispatch/contracts/derive_cli.py b/src/outfitter/dispatch/contracts/derive_cli.py index 96a6530..a194601 100644 --- a/src/outfitter/dispatch/contracts/derive_cli.py +++ b/src/outfitter/dispatch/contracts/derive_cli.py @@ -23,6 +23,7 @@ Renderer = Callable[[Op, dict[str, object]], None] _SendMode = Literal["send", "steer", "queue", "interject", "context"] +_SearchSortKey = Literal["created_at", "updated_at"] @dataclass(frozen=True) @@ -34,6 +35,9 @@ class CliRoute: _ROUTES: tuple[CliRoute, ...] = ( CliRoute(("new",), "new"), + CliRoute(("rename",), "lane-rename", ("old", "new")), + CliRoute(("archive",), "archive", ("target",)), + CliRoute(("restore",), "restore", ("target",)), CliRoute(("lane", "get"), "show", ("lane",)), CliRoute(("lane", "status"), "show", ("lane",)), CliRoute(("lane", "attach"), "attach", ("thread",)), @@ -42,7 +46,8 @@ class CliRoute: CliRoute(("lane", "fork"), "fork", ("lane",)), CliRoute(("lane", "rollback"), "rollback", ("lane",)), CliRoute(("lane", "compact"), "compact", ("lane",)), - CliRoute(("lane", "archive"), "archive", ("lane",)), + CliRoute(("lane", "archive"), "archive", ("target",)), + CliRoute(("lane", "restore"), "restore", ("target",)), CliRoute(("goal", "status"), "goal-get", ("lane",)), CliRoute(("goal", "clear"), "goal-clear", ("lane",)), CliRoute(("trigger", "add"), "trigger-add"), @@ -68,7 +73,14 @@ def derive_cli( _register_command(app, ("send",), _send_command(registry.get("send"), invoke, renderer)) _register_command(app, ("stop",), _stop_command(registry.get("stop"), invoke, renderer)) + _register_command(app, ("search",), _search_command(registry.get("search"), invoke, renderer)) _register_command(app, ("lane", "list"), _lane_list_command(registry, invoke, renderer), groups) + _register_command( + app, + ("lane", "search"), + _lane_search_command(registry.get("search"), invoke, renderer), + groups, + ) _register_command(app, ("lane", "tail"), _lane_tail_command(registry, invoke, renderer), groups) _register_command( app, @@ -227,6 +239,172 @@ def command( return command +def _search_command(op: Op, invoke: Invoker, render: Renderer) -> Callable[..., None]: + def command( + query: Annotated[str, typer.Argument(help="Substring/full-text query.")], + lane: Annotated[ + str | None, typer.Option("--lane", help="Limit to one lane/thread id.") + ] = None, + directory: Annotated[ + str | None, + typer.Option("--directory", "--dir", help="Only include threads under this directory."), + ] = None, + repo: Annotated[ + str | None, typer.Option("--repo", help="Only include threads under this repo root.") + ] = None, + managed: Annotated[bool, typer.Option("--managed", help="Only managed lanes.")] = False, + unmanaged: Annotated[ + bool, typer.Option("--unmanaged", help="Only unmanaged Codex threads.") + ] = False, + archived: Annotated[ + bool, typer.Option("--archived", help="Search archived threads.") + ] = False, + since: Annotated[ + str | None, typer.Option("--since", help="Inclusive ISO date/time lower bound.") + ] = None, + until: Annotated[ + str | None, typer.Option("--until", help="Inclusive ISO date/time upper bound.") + ] = None, + date_field: Annotated[ + _SearchSortKey, typer.Option("--date-field", help="Timestamp field for date filters.") + ] = "updated_at", + sort: Annotated[_SearchSortKey, typer.Option("--sort", help="App Server sort key.")] = ( + "updated_at" + ), + ascending: Annotated[bool, typer.Option("--ascending", help="Sort oldest first.")] = False, + limit: Annotated[int, typer.Option(help="Max matches to return.")] = 20, + max_scan: Annotated[ + int, typer.Option("--max-scan", help="Max App Server matches to scan.") + ] = 200, + json: Annotated[ + bool, typer.Option("--json", help="Render machine-readable JSON output.") + ] = False, + ) -> None: + _invoke_search( + op, + invoke, + render, + query=query, + lane=lane, + directory=directory, + repo=repo, + managed=managed, + unmanaged=unmanaged, + archived=archived, + since=since, + until=until, + date_field=date_field, + sort=sort, + ascending=ascending, + limit=limit, + max_scan=max_scan, + json=json, + ) + + command.__doc__ = op.summary + return command + + +def _lane_search_command(op: Op, invoke: Invoker, render: Renderer) -> Callable[..., None]: + def command( + lane: Annotated[str, typer.Argument(help="Lane id, @handle, or raw Codex thread id.")], + query: Annotated[str, typer.Argument(help="Substring/full-text query.")], + directory: Annotated[ + str | None, + typer.Option("--directory", "--dir", help="Only include threads under this directory."), + ] = None, + repo: Annotated[ + str | None, typer.Option("--repo", help="Only include threads under this repo root.") + ] = None, + archived: Annotated[ + bool, typer.Option("--archived", help="Search archived threads.") + ] = False, + since: Annotated[ + str | None, typer.Option("--since", help="Inclusive ISO date/time lower bound.") + ] = None, + until: Annotated[ + str | None, typer.Option("--until", help="Inclusive ISO date/time upper bound.") + ] = None, + date_field: Annotated[ + _SearchSortKey, typer.Option("--date-field", help="Timestamp field for date filters.") + ] = "updated_at", + limit: Annotated[int, typer.Option(help="Max matches to return.")] = 20, + max_scan: Annotated[ + int, typer.Option("--max-scan", help="Max transcript items to scan.") + ] = 200, + json: Annotated[ + bool, typer.Option("--json", help="Render machine-readable JSON output.") + ] = False, + ) -> None: + _invoke_search( + op, + invoke, + render, + query=query, + lane=lane, + directory=directory, + repo=repo, + managed=False, + unmanaged=False, + archived=archived, + since=since, + until=until, + date_field=date_field, + sort="updated_at", + ascending=False, + limit=limit, + max_scan=max_scan, + json=json, + ) + + command.__doc__ = op.summary + return command + + +def _invoke_search( + op: Op, + invoke: Invoker, + render: Renderer, + *, + query: str, + lane: str | None, + directory: str | None, + repo: str | None, + managed: bool, + unmanaged: bool, + archived: bool, + since: str | None, + until: str | None, + date_field: str, + sort: str, + ascending: bool, + limit: int, + max_scan: int, + json: bool, +) -> None: + result = invoke( + op.id, + { + "query": query, + "lane": lane, + "directory": directory, + "repo": repo, + "managed": managed, + "unmanaged": unmanaged, + "archived": archived, + "since": since, + "until": until, + "date_field": date_field, + "sort": sort, + "ascending": ascending, + "limit": limit, + "max_scan": max_scan, + }, + ) + render(op, result) + _ignore_json(json) + + def _lane_list_command( registry: OpRegistry, invoke: Invoker, render: Renderer ) -> Callable[..., None]: @@ -346,6 +524,10 @@ def command( def _schema_op_id(command: str) -> str: aliases = { "stop": "stop", + "search": "search", + "rename": "lane-rename", + "archive": "archive", + "restore": "restore", "lane-get": "show", "lane-status": "show", "lane-list": "roster", @@ -353,10 +535,12 @@ def _schema_op_id(command: str) -> str: "lane-attach": "attach", "lane-sync": "sync", "lane-rename": "lane-rename", + "lane-search": "search", "lane-fork": "fork", "lane-rollback": "rollback", "lane-compact": "compact", "lane-archive": "archive", + "lane-restore": "restore", "lane-tail": "transcript", "goal-status": "goal-get", "goal-set": "goal-set", diff --git a/src/outfitter/dispatch/contracts/derive_mcp.py b/src/outfitter/dispatch/contracts/derive_mcp.py index 643d36b..fac3acd 100644 --- a/src/outfitter/dispatch/contracts/derive_mcp.py +++ b/src/outfitter/dispatch/contracts/derive_mcp.py @@ -52,6 +52,7 @@ class _ToolGroup: ("transcript", "transcript"), ("watch", "watch"), ("goal_get", "goal-get"), + ("search", "search"), ), ), _ToolGroup( @@ -66,6 +67,7 @@ class _ToolGroup: ("rename", "lane-rename"), ("send", "send"), ("stop", "stop"), + ("restore", "restore"), ("goal_set", "goal-set"), ("fork", "fork"), ("compact", "compact"), diff --git a/src/outfitter/dispatch/contracts/errors.py b/src/outfitter/dispatch/contracts/errors.py index 7a94569..217e61c 100644 --- a/src/outfitter/dispatch/contracts/errors.py +++ b/src/outfitter/dispatch/contracts/errors.py @@ -61,7 +61,7 @@ class ApprovalRequiredError(DispatchError): class AuthorityError(DispatchError): - """A write was attempted on an observe-only attached lane (ADR-0005).""" + """A blocked write was attempted on an attached lane (ADR-0005/0018).""" code = "authority" exit_code = 7 diff --git a/src/outfitter/dispatch/core/handlers.py b/src/outfitter/dispatch/core/handlers.py index cb79d8d..05af59b 100644 --- a/src/outfitter/dispatch/core/handlers.py +++ b/src/outfitter/dispatch/core/handlers.py @@ -1,13 +1,17 @@ """Op handlers. Surface-agnostic: input + ctx in, output or raise out. They never import CLI/MCP/socket types; side effects go through ``ctx`` (ADR-0006). -Authority guard (ADR-0005): owned lanes are read/write; attached lanes are -observe-only — ``send``/``steer``/``brief``/``interrupt`` raise ``AuthorityError``. +Authority guard (ADR-0005/0018): owned lanes are read/write; attached lanes can +be observed and have explicit metadata/lifecycle actions, but turn-writing and +history-mutating ops still raise ``AuthorityError``. """ from __future__ import annotations import asyncio +from datetime import datetime, time +from pathlib import Path +from typing import cast from pydantic import ValidationError as PydanticValidationError @@ -29,7 +33,7 @@ ValidationError, project_error, ) -from outfitter.dispatch.registry.models import Lane, LaneSync, SyncState +from outfitter.dispatch.registry.models import Lane, LaneStatus, LaneSync, SyncState from . import queue from .models import ( @@ -63,10 +67,15 @@ RollbackInput, Roster, RosterInput, + SearchInput, + SearchMatch, + SearchOutput, SendInput, ShowInput, StatusInput, StatusOutput, + ThreadActionRef, + ThreadTargetInput, TranscriptInput, TranscriptItem, TranscriptOutput, @@ -90,6 +99,23 @@ def _ref(lane: Lane) -> LaneRef: return LaneRef(id=lane.id, handle=lane.handle, source=lane.source, status=lane.status) +def _action_ref( + *, + thread_id: str, + lane: Lane | None = None, + status: str | None = None, +) -> ThreadActionRef: + if lane is None: + return ThreadActionRef(id=thread_id, managed=False, source="unmanaged", status=status) + return ThreadActionRef( + id=lane.id, + handle=lane.handle, + managed=True, + source=lane.source, + status=status or lane.status, + ) + + def _sync_view(sync: LaneSync | None) -> LaneSyncView: if sync is None: return LaneSyncView() @@ -114,17 +140,31 @@ def _handle(name: str) -> str: async def _resolve(ctx: Ctx, ref: str) -> Lane: + lane = await _find_lane(ctx, ref) + if lane is None: + raise NotFoundError(f"no lane {ref!r}") + return lane + + +async def _find_lane(ctx: Ctx, ref: str) -> Lane | None: lane = await ctx.registry.find_lane(ref) if lane is None: lane = await ctx.registry.find_lane_by_handle(ref) - if lane is None: - raise NotFoundError(f"no lane {ref!r}") return lane +async def _resolve_thread_target(ctx: Ctx, ref: str) -> tuple[str, Lane | None]: + lane = await _find_lane(ctx, ref) + if lane is not None: + return lane.id, lane + if ref.startswith("@"): + raise NotFoundError(f"no lane {ref!r}") + return ref, None + + def _require_writable(lane: Lane) -> None: if lane.source == "attached": - raise AuthorityError(f"lane {lane.handle} is attached (observe-only; ADR-0005)") + raise AuthorityError(f"lane {lane.handle} is attached (turn-write locked; ADR-0005/0018)") def _require_active_turn(lane: Lane, action: str) -> str: @@ -483,20 +523,21 @@ async def sync_lane(inp: LaneSyncInput, ctx: Ctx) -> LaneSyncResult: return LaneSyncResult(lane=lane.id, sync=_sync_view(sync)) -async def rename_lane(inp: LaneRenameInput, ctx: Ctx) -> LaneRef: - lane = await _resolve(ctx, inp.old) +async def rename_lane(inp: LaneRenameInput, ctx: Ctx) -> ThreadActionRef: + thread_id, lane = await _resolve_thread_target(ctx, inp.old) + if lane is None: + await ctx.client.thread_set_name(thread_id, inp.new.removeprefix("@")) + await ctx.registry.log_action("lane-rename", lane=thread_id, detail=inp.new) + return _action_ref(thread_id=thread_id) + handle = _handle(inp.new) existing = await ctx.registry.find_lane_by_handle(handle) if existing is not None and existing.id != lane.id: raise ValidationError(f"lane handle {handle!r} is already registered") + await ctx.client.thread_set_name(lane.id, handle.removeprefix("@")) await ctx.registry.update_lane_handle(lane.id, handle) - if lane.source == "own": - try: - await ctx.client.thread_set_name(lane.id, handle.removeprefix("@")) - except ClientError as exc: - ctx.log.warning("lane.name_set_failed", lane=lane.id, error=str(exc)) await ctx.registry.log_action("lane-rename", lane=lane.id, detail=handle) - return _ref(await ctx.registry.get_lane(lane.id)) + return _action_ref(thread_id=lane.id, lane=await ctx.registry.get_lane(lane.id)) async def watch(inp: WatchInput, ctx: Ctx) -> WatchOutput: @@ -540,6 +581,67 @@ async def transcript(inp: TranscriptInput, ctx: Ctx) -> TranscriptOutput: ) +async def search(inp: SearchInput, ctx: Ctx) -> SearchOutput: + if inp.managed and inp.unmanaged: + raise ValidationError("search can filter --managed or --unmanaged, not both") + if inp.limit > inp.max_scan: + raise ValidationError("search limit cannot exceed max_scan") + + lane_map = {lane.id: lane for lane in await ctx.registry.list_lanes(include_archived=True)} + root_filters = _search_roots(inp) + since = _parse_bound(inp.since, start=True) + until = _parse_bound(inp.until, start=False) + + if inp.lane is not None: + return await _search_one_thread(inp, ctx, lane_map, root_filters, since, until) + + matches: list[SearchMatch] = [] + scanned = 0 + cursor: str | None = None + next_cursor: str | None = None + page_limit = min(max(inp.limit * 4, 20), 100) + while len(matches) < inp.limit and scanned < inp.max_scan: + response = await ctx.client.thread_search( + inp.query, + archived=inp.archived, + cursor=cursor, + limit=min(page_limit, inp.max_scan - scanned), + sort_direction="asc" if inp.ascending else "desc", + sort_key=inp.sort, + ) + if not response.data: + next_cursor = response.next_cursor + break + for candidate in response.data: + scanned += 1 + match = _search_match( + candidate.thread, + candidate.snippet, + lane_map=lane_map, + managed_only=inp.managed, + unmanaged_only=inp.unmanaged, + roots=root_filters, + date_field=inp.date_field, + since=since, + until=until, + ) + if match is not None: + matches.append(match) + if len(matches) >= inp.limit or scanned >= inp.max_scan: + break + cursor = response.next_cursor + next_cursor = response.next_cursor + if cursor is None: + break + + return SearchOutput( + query=inp.query, + matches=matches, + scanned=scanned, + next_cursor=next_cursor, + ) + + def _watch_event(raw: dict[str, object]) -> WatchEvent | None: method = raw.get("method") if not isinstance(method, str): @@ -584,6 +686,157 @@ def _transcript_from_thread(result: dict[str, object], *, limit: int) -> list[Tr return items[-limit:] +async def _search_one_thread( + inp: SearchInput, + ctx: Ctx, + lane_map: dict[str, Lane], + roots: tuple[Path, ...], + since: float | None, + until: float | None, +) -> SearchOutput: + assert inp.lane is not None + thread_id, _lane = await _resolve_thread_target(ctx, inp.lane) + result = await ctx.client.thread_read(thread_id, include_turns=True) + try: + thread = ThreadResult.model_validate(result).thread + except PydanticValidationError as exc: + raise AppServerError( + f"thread/read transcript for {thread_id!r} returned an invalid payload" + ) from exc + + if inp.managed and thread.id not in lane_map: + return SearchOutput(query=inp.query, matches=[], scanned=0) + if inp.unmanaged and thread.id in lane_map: + return SearchOutput(query=inp.query, matches=[], scanned=0) + if _outside_roots(thread.cwd, roots) or _outside_date( + thread, inp.date_field, since=since, until=until + ): + return SearchOutput(query=inp.query, matches=[], scanned=0) + + query = inp.query.casefold() + items = _transcript_from_thread(result, limit=inp.max_scan) + matches: list[SearchMatch] = [] + scanned = 0 + for item in items: + scanned += 1 + if item.text is None or query not in item.text.casefold(): + continue + match = _search_match( + thread, + _short(item.text, limit=200) or "", + lane_map=lane_map, + managed_only=False, + unmanaged_only=False, + roots=(), + date_field=inp.date_field, + since=None, + until=None, + ) + if match is not None: + matches.append(match) + if len(matches) >= inp.limit: + break + return SearchOutput(query=inp.query, matches=matches, scanned=scanned) + + +def _search_roots(inp: SearchInput) -> tuple[Path, ...]: + roots: list[Path] = [] + if inp.directory is not None: + roots.append(_normalize_path(inp.directory)) + if inp.repo is not None: + roots.append(_repo_root(inp.repo)) + return tuple(roots) + + +def _normalize_path(path: str) -> Path: + return Path(path).expanduser().resolve(strict=False) + + +def _repo_root(path: str) -> Path: + current = _normalize_path(path) + if current.is_file(): + current = current.parent + for candidate in (current, *current.parents): + if (candidate / ".git").exists(): + return candidate + raise ValidationError(f"no git repo found at or above {path!r}") + + +def _parse_bound(value: str | None, *, start: bool) -> float | None: + if value is None: + return None + text = value.strip() + if not text: + raise ValidationError("date bound cannot be empty") + try: + if len(text) == 10 and text[4] == "-" and text[7] == "-": + day = datetime.fromisoformat(text).date() + dt = datetime.combine(day, time.min if start else time.max) + else: + dt = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError as exc: + raise ValidationError(f"invalid ISO date/time: {value!r}") from exc + return dt.timestamp() + + +def _search_match( + thread: ThreadInfo, + snippet: str, + *, + lane_map: dict[str, Lane], + managed_only: bool, + unmanaged_only: bool, + roots: tuple[Path, ...], + date_field: str, + since: float | None, + until: float | None, +) -> SearchMatch | None: + lane = lane_map.get(thread.id) + if managed_only and lane is None: + return None + if unmanaged_only and lane is not None: + return None + if _outside_roots(thread.cwd, roots): + return None + if _outside_date(thread, date_field, since=since, until=until): + return None + + source = lane.source if lane is not None else "unmanaged" + return SearchMatch( + id=thread.id, + handle=lane.handle if lane is not None else None, + managed=lane is not None, + source=source, + status=lane.status if lane is not None else (thread.status.type if thread.status else None), + name=thread.name, + cwd=thread.cwd, + preview=_short(thread.preview), + snippet=snippet, + created_at=thread.created_at, + updated_at=thread.updated_at, + ) + + +def _outside_roots(cwd: str | None, roots: tuple[Path, ...]) -> bool: + if not roots: + return False + if cwd is None: + return True + path = _normalize_path(cwd) + return not all(path == root or path.is_relative_to(root) for root in roots) + + +def _outside_date( + thread: ThreadInfo, date_field: str, *, since: float | None, until: float | None +) -> bool: + if since is None and until is None: + return False + timestamp = thread.created_at if date_field == "created_at" else thread.updated_at + if timestamp is None: + return True + return (since is not None and timestamp < since) or (until is not None and timestamp > until) + + def _item_text(item: dict[str, object]) -> str | None: direct = item.get("text") if isinstance(direct, str): @@ -747,29 +1000,48 @@ def _session(thread: ThreadInfo) -> DiscoveredSession: async def discover(inp: DiscoverInput, ctx: Ctx) -> Discovery: """List persisted Codex sessions (``thread/list``, state-db only) — read-only and distinct from ``roster``: these are candidates to ``attach``, not managed lanes. - Discovery does not resume or register anything (ADR-0005 observe-only is untouched).""" + Discovery does not resume or register anything.""" threads = await ctx.client.thread_list(limit=inp.limit, use_state_db_only=True) return Discovery(sessions=[_session(thread) for thread in threads]) -async def archive(inp: LaneInput, ctx: Ctx) -> LaneRef: - lane = await _resolve(ctx, inp.lane) - _require_writable(lane) # archiving mutates the shared thread store (ADR-0005) +async def archive(inp: ThreadTargetInput, ctx: Ctx) -> ThreadActionRef: + thread_id, lane = await _resolve_thread_target(ctx, inp.target) try: - await ctx.client.thread_archive(lane.id) + await ctx.client.thread_archive(thread_id) except ClientAppServerError as exc: - if not _is_no_rollout_archive_error(exc): + if lane is None or not _is_no_rollout_archive_error(exc): raise - ctx.log.info("lane.archive_local_no_rollout", lane=lane.id) - await ctx.registry.update_lane_status(lane.id, "archived") - await ctx.registry.log_action("archive", lane=lane.id) - return _ref(await ctx.registry.get_lane(lane.id)) + ctx.log.info("lane.archive_local_no_rollout", lane=thread_id) + if lane is not None: + await ctx.registry.update_lane_status(lane.id, "archived") + lane = await ctx.registry.get_lane(lane.id) + await ctx.registry.log_action("archive", lane=thread_id) + return _action_ref(thread_id=thread_id, lane=lane, status="archived") def _is_no_rollout_archive_error(exc: ClientAppServerError) -> bool: return exc.code == -32600 and "no rollout found" in exc.message.lower() +async def restore(inp: ThreadTargetInput, ctx: Ctx) -> ThreadActionRef: + thread_id, lane = await _resolve_thread_target(ctx, inp.target) + thread = await ctx.client.thread_unarchive(thread_id) + status = _lane_status(thread) + if lane is not None: + await ctx.registry.update_lane_status(lane.id, status) + lane = await ctx.registry.get_lane(lane.id) + await ctx.registry.log_action("restore", lane=thread_id) + return _action_ref(thread_id=thread_id, lane=lane, status=status) + + +def _lane_status(thread: ThreadInfo) -> LaneStatus: + status = thread.status.type if thread.status is not None else None + if status in {"idle", "busy", "waiting_approval", "archived", "error", "unknown"}: + return cast(LaneStatus, status) + return "unknown" + + async def status(inp: StatusInput, ctx: Ctx) -> StatusOutput: lanes = await ctx.registry.list_lanes() triggers = await ctx.registry.list_triggers() diff --git a/src/outfitter/dispatch/core/models.py b/src/outfitter/dispatch/core/models.py index 08a14a0..24cbabb 100644 --- a/src/outfitter/dispatch/core/models.py +++ b/src/outfitter/dispatch/core/models.py @@ -21,6 +21,9 @@ # --- inputs ------------------------------------------------------------------- SendMode = Literal["send", "steer", "queue", "interject", "context"] +ThreadActionSource = Literal["own", "attached", "unmanaged"] +SearchSortKey = Literal["created_at", "updated_at"] +SearchDateField = Literal["created_at", "updated_at"] class OpenInput(BaseModel): @@ -83,14 +86,49 @@ class LaneInput(BaseModel): lane: str = Field(description="Lane id or @handle.") +class ThreadTargetInput(BaseModel): + target: str = Field(description="Lane id, @handle, or raw Codex thread id.") + + class LaneSyncInput(BaseModel): lane: str = Field(description="Lane id or @handle.") full: bool = Field(default=False, description="Scan the full source file instead of top+tail.") class LaneRenameInput(BaseModel): - old: str = Field(description="Existing lane id or @handle.") - new: str = Field(description="New lane handle; @ is optional.") + old: str = Field(description="Existing lane id, @handle, or raw Codex thread id.") + new: str = Field(description="New lane handle/thread name; @ is optional for managed lanes.") + + +class SearchInput(BaseModel): + query: str = Field(description="Substring/full-text query for Codex thread search.") + lane: str | None = Field( + default=None, description="Limit search to one lane id, @handle, or raw Codex thread id." + ) + directory: str | None = Field( + default=None, description="Only include threads whose cwd is inside this directory." + ) + repo: str | None = Field( + default=None, description="Only include threads whose cwd is inside this repo root." + ) + managed: bool = Field(default=False, description="Only include dispatch-managed lanes.") + unmanaged: bool = Field(default=False, description="Only include unmanaged Codex threads.") + archived: bool = Field( + default=False, description="Search archived threads instead of active ones." + ) + since: str | None = Field(default=None, description="Inclusive ISO date/time lower bound.") + until: str | None = Field(default=None, description="Inclusive ISO date/time upper bound.") + date_field: SearchDateField = Field( + default="updated_at", description="Timestamp field used for since/until filtering." + ) + sort: SearchSortKey = Field(default="updated_at", description="App Server search sort key.") + ascending: bool = Field(default=False, description="Sort oldest first.") + limit: int = Field(default=20, ge=1, description="Max matches to return.") + max_scan: int = Field( + default=200, + ge=1, + description="Max App Server matches to scan while applying dispatch-side filters.", + ) class ShowInput(BaseModel): @@ -182,6 +220,14 @@ class LaneRef(BaseModel): status: LaneStatus +class ThreadActionRef(BaseModel): + id: str + handle: str | None = None + managed: bool + source: ThreadActionSource + status: str | None = None + + class LaneSyncView(BaseModel): state: SyncState = "unknown" last_synced_at: str | None = None @@ -281,6 +327,28 @@ class Discovery(BaseModel): sessions: list[DiscoveredSession] +class SearchMatch(BaseModel): + id: str + handle: str | None = None + managed: bool + source: ThreadActionSource + status: str | None = None + name: str | None = None + cwd: str | None = None + preview: str | None = None + snippet: str + created_at: int | None = None + updated_at: int | None = None + + +class SearchOutput(BaseModel): + query: str + matches: list[SearchMatch] + scanned: int + next_cursor: str | None = None + experimental: bool = True + + # --- trigger ops -------------------------------------------------------------- TriggerWhenKind = Literal["interval", "cron", "idle_for", "turn_completed", "waiting_on_approval"] diff --git a/src/outfitter/dispatch/core/ops.py b/src/outfitter/dispatch/core/ops.py index 03f1195..a79d4c8 100644 --- a/src/outfitter/dispatch/core/ops.py +++ b/src/outfitter/dispatch/core/ops.py @@ -35,10 +35,14 @@ RollbackInput, Roster, RosterInput, + SearchInput, + SearchOutput, SendInput, ShowInput, StatusInput, StatusOutput, + ThreadActionRef, + ThreadTargetInput, TranscriptInput, TranscriptOutput, TriggerAddInput, @@ -93,7 +97,7 @@ ATTACH = define_op( id="attach", - summary="Attach to an existing lane (observe-only; ADR-0005).", + summary="Attach to an existing lane (turn-write locked; ADR-0005).", input=AttachInput, output=LaneRef, intent="write", @@ -145,13 +149,25 @@ LANE_RENAME = define_op( id="lane-rename", - summary="Rename a managed lane handle.", + summary="Rename a lane handle or Codex thread.", input=LaneRenameInput, - output=LaneRef, + output=ThreadActionRef, intent="write", idempotent=False, handler=handlers.rename_lane, - examples=[Example("missing", input={"old": "nope", "new": "docs"}, raises=NotFoundError)], + examples=[ + Example( + "unmanaged", + input={"old": "T1", "new": "docs"}, + output={ + "id": "T1", + "handle": None, + "managed": False, + "source": "unmanaged", + "status": None, + }, + ) + ], ) TRANSCRIPT = define_op( @@ -209,15 +225,73 @@ examples=[Example("empty", input={"limit": 50}, output={"sessions": []})], ) +SEARCH = define_op( + id="search", + summary="Search Codex thread history.", + input=SearchInput, + output=SearchOutput, + intent="read", + idempotent=True, + handler=handlers.search, + examples=[ + Example( + "empty", + input={"query": "needle"}, + output={ + "query": "needle", + "matches": [], + "scanned": 0, + "next_cursor": None, + "experimental": True, + }, + ) + ], +) + ARCHIVE = define_op( id="archive", - summary="Archive a lane (reversible).", - input=LaneInput, - output=LaneRef, + summary="Archive a lane or unmanaged Codex thread.", + input=ThreadTargetInput, + output=ThreadActionRef, intent="destroy", idempotent=True, handler=handlers.archive, - examples=[Example("missing", input={"lane": "nope"}, raises=NotFoundError)], + examples=[ + Example( + "unmanaged", + input={"target": "T1"}, + output={ + "id": "T1", + "handle": None, + "managed": False, + "source": "unmanaged", + "status": "archived", + }, + ) + ], +) + +RESTORE = define_op( + id="restore", + summary="Restore an archived lane or unmanaged Codex thread.", + input=ThreadTargetInput, + output=ThreadActionRef, + intent="write", + idempotent=True, + handler=handlers.restore, + examples=[ + Example( + "unmanaged", + input={"target": "T1"}, + output={ + "id": "T1", + "handle": None, + "managed": False, + "source": "unmanaged", + "status": "unknown", + }, + ) + ], ) GOAL_GET = define_op( @@ -390,7 +464,9 @@ SYNC, ROSTER, DISCOVER, + SEARCH, ARCHIVE, + RESTORE, GOAL_GET, GOAL_SET, GOAL_CLEAR, diff --git a/src/outfitter/dispatch/daemon/supervisor.py b/src/outfitter/dispatch/daemon/supervisor.py index fe8a809..bc2f00d 100644 --- a/src/outfitter/dispatch/daemon/supervisor.py +++ b/src/outfitter/dispatch/daemon/supervisor.py @@ -80,7 +80,7 @@ async def _restore_lanes(self, client: SupervisedClient) -> None: Owned lanes are resumed so their app-server event stream is reattached. Attached lanes stay metadata-only per ADR-0017; restarting the daemon must - not turn an observe-only registration into an implicit resume. + not turn registration into an implicit resume. """ for lane in await self._ctx.registry.list_lanes(): try: diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 09df434..e31986e 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -75,6 +75,45 @@ async def test_thread_set_name_sends_verified_method( } +async def test_thread_unarchive_sends_verified_method( + client: tuple[AppServerClient, FakeTransport], +) -> None: + c, fake = client + fake.auto = _result_for("thread/unarchive", {"thread": {"id": "L1"}}) + thread = await c.thread_unarchive("L1") + assert thread.id == "L1" + assert fake.sent[-1] == { + "id": 1, + "method": "thread/unarchive", + "params": {"threadId": "L1"}, + } + + +async def test_thread_search_sends_experimental_query_shape( + client: tuple[AppServerClient, FakeTransport], +) -> None: + c, fake = client + fake.auto = _result_for( + "thread/search", + {"data": [{"snippet": "hello", "thread": {"id": "L1", "cwd": "/repo"}}]}, + ) + result = await c.thread_search( + "hello", archived=True, limit=10, sort_direction="asc", sort_key="updated_at" + ) + assert result.data[0].thread.id == "L1" + assert fake.sent[-1] == { + "id": 1, + "method": "thread/search", + "params": { + "searchTerm": "hello", + "archived": True, + "limit": 10, + "sortDirection": "asc", + "sortKey": "updated_at", + }, + } + + async def test_thread_read_can_include_turns( client: tuple[AppServerClient, FakeTransport], ) -> None: diff --git a/tests/core/test_examples.py b/tests/core/test_examples.py index fd6f8bd..0dd7f94 100644 --- a/tests/core/test_examples.py +++ b/tests/core/test_examples.py @@ -23,7 +23,9 @@ async def test_registry_has_the_v1_ops() -> None: "sync", "roster", "discover", + "search", "archive", + "restore", "goal-get", "goal-set", "goal-clear", diff --git a/tests/core/test_handlers.py b/tests/core/test_handlers.py index 83fc4d4..37aa343 100644 --- a/tests/core/test_handlers.py +++ b/tests/core/test_handlers.py @@ -11,8 +11,19 @@ from outfitter.dispatch.client.errors import AppServerError as ClientAppServerError from outfitter.dispatch.client.errors import TransportError -from outfitter.dispatch.client.models import ThreadGoal, ThreadInfo, ThreadStatus -from outfitter.dispatch.contracts.errors import AppServerError, AuthorityError, ValidationError +from outfitter.dispatch.client.models import ( + ThreadGoal, + ThreadInfo, + ThreadSearchMatch, + ThreadSearchResult, + ThreadStatus, +) +from outfitter.dispatch.contracts.errors import ( + AppServerError, + AuthorityError, + NotFoundError, + ValidationError, +) from outfitter.dispatch.core import handlers from outfitter.dispatch.core.models import ( AttachInput, @@ -31,9 +42,11 @@ OpenInput, RollbackInput, RosterInput, + SearchInput, SendInput, ShowInput, StatusInput, + ThreadTargetInput, TranscriptInput, WatchInput, ) @@ -219,6 +232,51 @@ async def test_lane_rename_updates_registry_and_owned_thread_name(store: Registr ) +async def test_lane_rename_updates_attached_thread_name(store: Registry) -> None: + client = FakeLaneClient() + ctx = make_ctx(store, client) + await store.add_lane(id="D1", handle="@desktop", source="attached", status="idle") + + out = await handlers.rename_lane(LaneRenameInput(old="@desktop", new="renamed"), ctx) + + assert out.handle == "@renamed" + assert out.source == "attached" + assert any( + name == "thread_set_name" and kw["thread_id"] == "D1" and kw["display_name"] == "renamed" + for name, kw in client.calls + ) + + +async def test_lane_rename_can_target_unmanaged_thread(store: Registry) -> None: + client = FakeLaneClient() + ctx = make_ctx(store, client) + + out = await handlers.rename_lane(LaneRenameInput(old="raw-thread", new="Raw Name"), ctx) + + assert out.id == "raw-thread" + assert out.managed is False + assert out.source == "unmanaged" + assert (await handlers.roster(RosterInput(include_archived=True), ctx)).lanes == [] + assert any( + name == "thread_set_name" + and kw["thread_id"] == "raw-thread" + and kw["display_name"] == "Raw Name" + for name, kw in client.calls + ) + + +async def test_unresolved_handle_does_not_fall_through_as_raw_thread_id( + store: Registry, +) -> None: + client = FakeLaneClient() + ctx = make_ctx(store, client) + + with pytest.raises(NotFoundError): + await handlers.rename_lane(LaneRenameInput(old="@missing", new="new"), ctx) + + assert not client.calls + + async def test_show_can_include_compact_transcript(store: Registry) -> None: client = FakeLaneClient() client.read_result = { @@ -456,15 +514,21 @@ async def test_send_to_attached_lane_raises_authority(store: Registry) -> None: await handlers.send(LaneTextInput(lane="D1", text="nope"), ctx) -async def test_archive_attached_lane_raises_authority(store: Registry) -> None: - ctx = make_ctx(store) +async def test_archive_attached_lane_updates_thread_and_registry(store: Registry) -> None: + client = FakeLaneClient() + ctx = make_ctx(store, client) await store.add_lane(id="D2", handle="@desktop", source="attached", status="idle") - with pytest.raises(AuthorityError): - await handlers.archive(LaneInput(lane="D2"), ctx) + + out = await handlers.archive(ThreadTargetInput(target="D2"), ctx) + + assert out.status == "archived" + assert (await store.get_lane("D2")).status == "archived" + assert any(name == "thread_archive" and kw["thread_id"] == "D2" for name, kw in client.calls) async def test_steer_attached_lane_raises_authority(store: Registry) -> None: - # The authority guard precedes the active-turn check: attached lanes never write. + # The authority guard precedes the active-turn check: attached lanes do not accept + # turn-writing operations. ctx = make_ctx(store) await store.add_lane(id="D3", handle="@desktop", source="attached", status="idle") await store.set_active_turn("D3", "turn-1") @@ -538,7 +602,7 @@ async def test_roster_then_archive_flips_status(store: Registry) -> None: await handlers.open_lane(OpenInput(name="one"), ctx) roster = await handlers.roster(RosterInput(), ctx) assert [lane.handle for lane in roster.lanes] == ["@one"] - archived = await handlers.archive(LaneInput(lane="lane-1"), ctx) + archived = await handlers.archive(ThreadTargetInput(target="lane-1"), ctx) assert archived.status == "archived" assert (await handlers.roster(RosterInput(), ctx)).lanes == [] everything = await handlers.roster(RosterInput(include_archived=True), ctx) @@ -556,7 +620,7 @@ async def test_archive_no_rollout_lane_marks_local_lane_archived(store: Registry ctx = make_ctx(store, client) await handlers.new_lane(NewInput(name="smoke", ephemeral=True, send=False), ctx) - archived = await handlers.archive(LaneInput(lane="lane-1"), ctx) + archived = await handlers.archive(ThreadTargetInput(target="lane-1"), ctx) assert archived.status == "archived" assert (await handlers.roster(RosterInput(), ctx)).lanes == [] @@ -565,6 +629,55 @@ async def test_archive_no_rollout_lane_marks_local_lane_archived(store: Registry assert any(name == "thread_archive" for name, _ in client.calls) +async def test_archive_unmanaged_thread_does_not_register_lane(store: Registry) -> None: + client = FakeLaneClient() + ctx = make_ctx(store, client) + + out = await handlers.archive(ThreadTargetInput(target="raw-thread"), ctx) + + assert out.id == "raw-thread" + assert out.managed is False + assert out.status == "archived" + assert (await handlers.roster(RosterInput(include_archived=True), ctx)).lanes == [] + assert any( + name == "thread_archive" and kw["thread_id"] == "raw-thread" for name, kw in client.calls + ) + + +async def test_restore_managed_lane_unarchives_without_starting_turn(store: Registry) -> None: + client = FakeLaneClient() + ctx = make_ctx(store, client) + await handlers.open_lane(OpenInput(name="one"), ctx) + client.threads["lane-1"] = ThreadInfo(id="lane-1", status=ThreadStatus(type="idle")) + await store.update_lane_status("lane-1", "archived") + + restored = await handlers.restore(ThreadTargetInput(target="@one"), ctx) + + assert restored.status == "idle" + assert (await store.get_lane("lane-1")).status == "idle" + assert any( + name == "thread_unarchive" and kw["thread_id"] == "lane-1" for name, kw in client.calls + ) + assert not any(name == "turn_start" for name, _ in client.calls) + + +async def test_restore_unmanaged_thread_does_not_register_or_start_turn(store: Registry) -> None: + client = FakeLaneClient() + client.threads["raw-thread"] = ThreadInfo(id="raw-thread", status=ThreadStatus(type="idle")) + ctx = make_ctx(store, client) + + restored = await handlers.restore(ThreadTargetInput(target="raw-thread"), ctx) + + assert restored.id == "raw-thread" + assert restored.managed is False + assert restored.status == "idle" + assert (await handlers.roster(RosterInput(include_archived=True), ctx)).lanes == [] + assert any( + name == "thread_unarchive" and kw["thread_id"] == "raw-thread" for name, kw in client.calls + ) + assert not any(name == "turn_start" for name, _ in client.calls) + + async def test_status_and_log_reflect_activity(store: Registry) -> None: ctx = make_ctx(store) await handlers.open_lane(OpenInput(name="one"), ctx) @@ -724,7 +837,7 @@ async def test_discover_lists_persisted_sessions_from_client(store: Registry) -> name == "thread_list" and kw["limit"] == 10 and kw["use_state_db_only"] is True for name, kw in client.calls ) - # ...and registers nothing (pure read; ADR-0005 observe-only untouched). + # ...and registers nothing (pure read; lane authority untouched). assert (await handlers.roster(RosterInput(), ctx)).lanes == [] @@ -747,3 +860,114 @@ async def test_discover_keeps_short_preview_verbatim(store: Registry) -> None: out = await handlers.discover(DiscoverInput(), ctx) # At the boundary the preview is returned unchanged — no ellipsis. assert out.sessions[0].preview == exactly_80 + + +async def test_search_uses_app_server_and_filters_managed_state_and_repo( + store: Registry, tmp_path: Path +) -> None: + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + outside = tmp_path / "outside" + outside.mkdir() + client = FakeLaneClient() + client.search_result = ThreadSearchResult( + data=[ + ThreadSearchMatch( + snippet="needle in managed", + thread=ThreadInfo( + id="M1", + name="Managed", + cwd=str(repo / "subdir"), + created_at=100_000, + updated_at=200_000, + preview="managed preview", + status=ThreadStatus(type="idle"), + ), + ), + ThreadSearchMatch( + snippet="needle in unmanaged", + thread=ThreadInfo( + id="U1", + name="Unmanaged", + cwd=str(outside), + created_at=100_000, + updated_at=200_000, + status=ThreadStatus(type="idle"), + ), + ), + ] + ) + ctx = make_ctx(store, client) + await store.add_lane(id="M1", handle="@managed", source="attached", status="idle") + + out = await handlers.search( + SearchInput(query="needle", managed=True, repo=str(repo), since="1970-01-01"), + ctx, + ) + + assert [match.id for match in out.matches] == ["M1"] + assert out.matches[0].handle == "@managed" + assert out.matches[0].managed is True + assert out.scanned == 2 + assert any( + name == "thread_search" and kw["search_term"] == "needle" and kw["sort_key"] == "updated_at" + for name, kw in client.calls + ) + + +async def test_search_can_filter_unmanaged_threads(store: Registry) -> None: + client = FakeLaneClient() + client.search_result = ThreadSearchResult( + data=[ + ThreadSearchMatch(snippet="needle", thread=ThreadInfo(id="managed")), + ThreadSearchMatch(snippet="needle", thread=ThreadInfo(id="raw")), + ] + ) + ctx = make_ctx(store, client) + await store.add_lane(id="managed", handle="@managed", source="attached", status="idle") + + out = await handlers.search(SearchInput(query="needle", unmanaged=True), ctx) + + assert [match.id for match in out.matches] == ["raw"] + assert out.matches[0].source == "unmanaged" + + +async def test_lane_search_reads_one_thread_transcript(store: Registry) -> None: + client = FakeLaneClient() + client.read_result = { + "thread": { + "id": "lane-1", + "name": "Docs", + "cwd": "/work", + "updatedAt": 200, + "turns": [ + { + "id": "t1", + "items": [ + {"id": "a1", "type": "agentMessage", "text": "nothing here"}, + {"id": "a2", "type": "agentMessage", "text": "needle appears"}, + ], + } + ], + } + } + ctx = make_ctx(store, client) + await handlers.open_lane(OpenInput(name="docs"), ctx) + + out = await handlers.search(SearchInput(query="needle", lane="@docs"), ctx) + + assert [match.snippet for match in out.matches] == ["needle appears"] + assert out.matches[0].handle == "@docs" + assert out.scanned == 2 + assert any( + name == "thread_read" and kw["thread_id"] == "lane-1" and kw["include_turns"] is True + for name, kw in client.calls + ) + assert not any(name == "thread_search" for name, _ in client.calls) + + +async def test_search_rejects_conflicting_managed_filters(store: Registry) -> None: + ctx = make_ctx(store) + with pytest.raises(ValidationError): + await handlers.search(SearchInput(query="needle", managed=True, unmanaged=True), ctx) diff --git a/tests/daemon/test_control.py b/tests/daemon/test_control.py index c575b3a..3b7f41c 100644 --- a/tests/daemon/test_control.py +++ b/tests/daemon/test_control.py @@ -57,7 +57,7 @@ async def test_open_send_show_roster_archive_via_daemon(socket_path: Path) -> No lanes = _result(roster)["lanes"] assert isinstance(lanes, list) and len(lanes) == 1 - archived = await _call(socket_path, "archive", {"lane": "lane-1"}) + archived = await _call(socket_path, "archive", {"target": "lane-1"}) assert _result(archived)["status"] == "archived" diff --git a/tests/fakes.py b/tests/fakes.py index 5c80cfb..469db9b 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -21,10 +21,15 @@ Personality, ReasoningSummary, SandboxPolicy, + SortDirection, ThreadGoal, ThreadGoalStatus, ThreadInfo, ThreadSandbox, + ThreadSearchMatch, + ThreadSearchResult, + ThreadSortKey, + ThreadSourceKind, ) from outfitter.dispatch.contracts.context import Ctx from outfitter.dispatch.registry.store import Registry @@ -44,6 +49,7 @@ def __init__(self) -> None: self.threads: dict[str, ThreadInfo] = {} self.list_result: list[ThreadInfo] = [] self.read_result: dict[str, object] = {} + self.search_result = ThreadSearchResult() self.goal_result: ThreadGoal | None = None self.event_log: list[LaneEvent] = [] self.raw_log: list[dict[str, object]] = [] @@ -136,9 +142,45 @@ async def thread_read(self, thread_id: str, include_turns: bool = False) -> dict async def thread_archive(self, thread_id: str) -> None: self._record("thread_archive", thread_id=thread_id) + async def thread_unarchive(self, thread_id: str) -> ThreadInfo: + self._record("thread_unarchive", thread_id=thread_id) + return self.threads.get(thread_id, ThreadInfo(id=thread_id)) + async def thread_set_name(self, thread_id: str, name: str) -> None: self._record("thread_set_name", thread_id=thread_id, display_name=name) + async def thread_search( + self, + search_term: str, + *, + archived: bool | None = None, + cursor: str | None = None, + limit: int | None = None, + sort_direction: SortDirection | None = None, + sort_key: ThreadSortKey | None = None, + source_kinds: list[ThreadSourceKind] | None = None, + ) -> ThreadSearchResult: + self._record( + "thread_search", + search_term=search_term, + archived=archived, + cursor=cursor, + limit=limit, + sort_direction=sort_direction, + sort_key=sort_key, + source_kinds=source_kinds, + ) + if limit is None: + return self.search_result + return ThreadSearchResult( + data=[ + ThreadSearchMatch(snippet=match.snippet, thread=match.thread) + for match in self.search_result.data[:limit] + ], + next_cursor=self.search_result.next_cursor, + backwards_cursor=self.search_result.backwards_cursor, + ) + async def thread_rollback(self, thread_id: str, num_turns: int) -> ThreadInfo: self._record("thread_rollback", thread_id=thread_id, num_turns=num_turns) return self.threads.get(thread_id, ThreadInfo(id=thread_id)) diff --git a/tests/integration/test_app_server.py b/tests/integration/test_app_server.py index a4bdada..b035f84 100644 --- a/tests/integration/test_app_server.py +++ b/tests/integration/test_app_server.py @@ -82,7 +82,7 @@ async def test_persisted_resume_yields_live_events(client: AppServerClient, work # dispatch's real topology: ONE app-server, many lanes on one connection. Resuming # a persisted thread on that connection yields live events for the next turn. # (Cross-PROCESS live fan-out does NOT happen — recorded in RETRO/ADR-0005 from the - # Phase-1 spike; that is why attached lanes stay observe-only.) + # Phase-1 spike; that is why attached lanes stay turn-write locked.) thread = await client.thread_start(cwd=str(work_dir), sandbox="read-only", ephemeral=False) await run_turn(client, thread.id, "Reply one word: alpha", str(work_dir)) # persist a rollout resumed = await client.thread_resume(thread.id) @@ -137,8 +137,37 @@ async def test_thread_read_goal_and_history_controls( await client.thread_archive(thread.id) +async def test_thread_search_and_unarchive_primitives( + client: AppServerClient, work_dir: Path +) -> None: + thread = await client.thread_start(cwd=str(work_dir), sandbox="read-only", ephemeral=False) + try: + await run_turn( + client, + thread.id, + "Reply with exactly one word: dispatchsearchneedle", + str(work_dir), + ) + assert await _await_search_match(client, "dispatchsearchneedle", thread.id) + + await client.thread_archive(thread.id) + restored = await client.thread_unarchive(thread.id) + assert restored.id == thread.id + finally: + await client.thread_archive(thread.id) + + async def _await_completion(events: AsyncIterator[LaneEvent], lane: str) -> bool: async for event in events: if isinstance(event, TurnCompleted) and event.lane_id == lane: return True return False + + +async def _await_search_match(client: AppServerClient, query: str, thread_id: str) -> bool: + for _ in range(10): + result = await client.thread_search(query, limit=20) + if any(match.thread.id == thread_id for match in result.data): + return True + await asyncio.sleep(0.25) + return False diff --git a/tests/surfaces/test_derive_cli.py b/tests/surfaces/test_derive_cli.py index d4119e8..5d2c787 100644 --- a/tests/surfaces/test_derive_cli.py +++ b/tests/surfaces/test_derive_cli.py @@ -78,6 +78,61 @@ def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: assert params["send"] is False +def test_top_level_thread_actions_route_to_lane_contracts() -> None: + calls: list[tuple[str, dict[str, object]]] = [] + + def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: + calls.append((op_id, params)) + if op_id == "search": + return { + "query": "needle", + "matches": [], + "scanned": 0, + "next_cursor": None, + "experimental": True, + } + return { + "id": "L1", + "handle": "@x", + "managed": True, + "source": "own", + "status": "idle", + } + + app = derive_cli(REGISTRY, invoke) + + renamed = runner.invoke(app, ["rename", "@old", "new"]) + restored = runner.invoke(app, ["restore", "@old"]) + searched = runner.invoke(app, ["search", "needle", "--lane", "@old", "--limit", "5"]) + + assert renamed.exit_code == 0 + assert restored.exit_code == 0 + assert searched.exit_code == 0 + assert calls == [ + ("lane-rename", {"old": "@old", "new": "new"}), + ("restore", {"target": "@old"}), + ( + "search", + { + "query": "needle", + "lane": "@old", + "directory": None, + "repo": None, + "managed": False, + "unmanaged": False, + "archived": False, + "since": None, + "until": None, + "date_field": "updated_at", + "sort": "updated_at", + "ascending": False, + "limit": 5, + "max_scan": 200, + }, + ), + ] + + def test_lane_group_routes_core_lane_commands() -> None: calls: list[tuple[str, dict[str, object]]] = [] @@ -86,7 +141,29 @@ def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: if op_id == "discover": return {"sessions": []} if op_id == "lane-rename": - return {"id": "L1", "handle": "@new", "source": "own", "status": "idle"} + return { + "id": "L1", + "handle": "@new", + "managed": True, + "source": "own", + "status": "idle", + } + if op_id == "search": + return { + "query": "needle", + "matches": [], + "scanned": 0, + "next_cursor": None, + "experimental": True, + } + if op_id == "restore": + return { + "id": "L1", + "handle": "@old", + "managed": True, + "source": "own", + "status": "idle", + } return {"lanes": []} app = derive_cli(REGISTRY, invoke) @@ -94,14 +171,38 @@ def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: managed = runner.invoke(app, ["lane", "list"]) unmanaged = runner.invoke(app, ["lane", "list", "--unmanaged", "--limit", "5"]) renamed = runner.invoke(app, ["lane", "rename", "@old", "new"]) + lane_search = runner.invoke(app, ["lane", "search", "@old", "needle"]) + restored = runner.invoke(app, ["lane", "restore", "@old"]) assert managed.exit_code == 0 assert unmanaged.exit_code == 0 assert renamed.exit_code == 0 + assert lane_search.exit_code == 0 + assert restored.exit_code == 0 assert calls == [ ("roster", {"include_archived": False}), ("discover", {"limit": 5}), ("lane-rename", {"old": "@old", "new": "new"}), + ( + "search", + { + "query": "needle", + "lane": "@old", + "directory": None, + "repo": None, + "managed": False, + "unmanaged": False, + "archived": False, + "since": None, + "until": None, + "date_field": "updated_at", + "sort": "updated_at", + "ascending": False, + "limit": 20, + "max_scan": 200, + }, + ), + ("restore", {"target": "@old"}), ] @@ -142,7 +243,13 @@ def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: def test_lane_archive_prompts_for_confirmation() -> None: def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: - return {"id": "L1", "handle": "@x", "source": "own", "status": "archived"} + return { + "id": "L1", + "handle": "@x", + "managed": True, + "source": "own", + "status": "archived", + } app = derive_cli(REGISTRY, invoke) declined = runner.invoke(app, ["lane", "archive", "L1"], input="n\n") @@ -153,7 +260,13 @@ def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: def test_json_destroy_prompt_does_not_pollute_stdout() -> None: def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: - return {"id": "L1", "handle": "@x", "source": "own", "status": "archived"} + return { + "id": "L1", + "handle": "@x", + "managed": True, + "source": "own", + "status": "archived", + } app = derive_cli(REGISTRY, invoke) result = runner.invoke(app, ["lane", "archive", "L1", "--json"], input="y\n") @@ -164,6 +277,7 @@ def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: assert json.loads(payload) == { "id": "L1", "handle": "@x", + "managed": True, "source": "own", "status": "archived", } @@ -194,14 +308,21 @@ def test_schema_command_resolves_composed_cli_routes() -> None: app = derive_cli(REGISTRY, lambda _op, _params: {}) unmanaged = runner.invoke(app, ["schema", "lane list --unmanaged"]) + search = runner.invoke(app, ["schema", "search"]) + lane_search = runner.invoke(app, ["schema", "lane search"]) follow = runner.invoke(app, ["schema", "lane tail --follow"]) fork = runner.invoke(app, ["schema", "lane fork"]) rollback = runner.invoke(app, ["schema", "lane rollback"]) compact = runner.invoke(app, ["schema", "lane compact"]) archive = runner.invoke(app, ["schema", "lane archive"]) + restore = runner.invoke(app, ["schema", "restore"]) assert unmanaged.exit_code == 0 assert '"op": "discover"' in unmanaged.output + assert search.exit_code == 0 + assert '"op": "search"' in search.output + assert lane_search.exit_code == 0 + assert '"op": "search"' in lane_search.output assert follow.exit_code == 0 assert '"op": "watch"' in follow.output assert '"timeout"' in follow.output @@ -213,6 +334,8 @@ def test_schema_command_resolves_composed_cli_routes() -> None: assert '"op": "compact"' in compact.output assert archive.exit_code == 0 assert '"op": "archive"' in archive.output + assert restore.exit_code == 0 + assert '"op": "restore"' in restore.output def test_schema_command_unknown_target_is_usage_error() -> None: diff --git a/tests/surfaces/test_derive_mcp.py b/tests/surfaces/test_derive_mcp.py index 6bd3d63..fdc1cfa 100644 --- a/tests/surfaces/test_derive_mcp.py +++ b/tests/surfaces/test_derive_mcp.py @@ -39,7 +39,12 @@ def test_action_schema_and_annotations_from_op() -> None: one_of = lane_write.inputSchema["oneOf"] new_schema = next(s for s in one_of if s["properties"]["op"]["const"] == "new") assert set(new_schema["properties"]) >= {"op", "name", "preset", "text", "send"} - assert {s["properties"]["op"]["const"] for s in one_of} >= {"fork", "goal_set", "compact"} + assert {s["properties"]["op"]["const"] for s in one_of} >= { + "fork", + "goal_set", + "compact", + "restore", + } lane_read = tools["dispatch_lane_read"] assert lane_read.annotations is not None @@ -49,6 +54,7 @@ def test_action_schema_and_annotations_from_op() -> None: "transcript", "watch", "goal_get", + "search", } lane_destroy = tools["dispatch_lane_destroy"] diff --git a/tests/surfaces/test_parity.py b/tests/surfaces/test_parity.py index f1ad3a8..82ba25b 100644 --- a/tests/surfaces/test_parity.py +++ b/tests/surfaces/test_parity.py @@ -36,6 +36,10 @@ def _stub_invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: "send": "send", "stop": "stop", "new": "new", + "search": "search", + "rename": "lane-rename", + "archive": "archive", + "restore": "restore", "lane get": "show", "lane status": "show", "lane list": "roster", @@ -43,10 +47,12 @@ def _stub_invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: "lane attach": "attach", "lane sync": "sync", "lane rename": "lane-rename", + "lane search": "search", "lane fork": "fork", "lane rollback": "rollback", "lane compact": "compact", "lane archive": "archive", + "lane restore": "restore", "lane tail": "transcript", "lane tail --follow": "watch", "goal status": "goal-get", @@ -110,6 +116,14 @@ def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: return {"lane": "L1", "op": "stop", "accepted": True} if op_id == "send": return {"lane": "L1", "op": "send", "accepted": True} + if op_id == "search": + return { + "query": "needle", + "matches": [], + "scanned": 0, + "next_cursor": None, + "experimental": True, + } if op_id == "goal-get": return {"lane": "L1", "goal": None} return {} @@ -120,6 +134,7 @@ def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: assert runner.invoke(app, ["stop", "@docs"]).exit_code == 0 assert runner.invoke(app, ["lane", "list", "--unmanaged"]).exit_code == 0 assert runner.invoke(app, ["lane", "sync", "@docs"]).exit_code == 0 + assert runner.invoke(app, ["search", "needle", "--managed"]).exit_code == 0 assert runner.invoke(app, ["goal", "status", "@docs"]).exit_code == 0 assert calls == [ @@ -127,6 +142,25 @@ def invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: ("stop", {"lane": "@docs"}), ("discover", {"limit": 50}), ("sync", {"lane": "@docs", "full": False}), + ( + "search", + { + "query": "needle", + "lane": None, + "directory": None, + "repo": None, + "managed": True, + "unmanaged": False, + "archived": False, + "since": None, + "until": None, + "date_field": "updated_at", + "sort": "updated_at", + "ascending": False, + "limit": 20, + "max_scan": 200, + }, + ), ("goal-get", {"lane": "@docs"}), ]