diff --git a/.agents/plans/2026-06-16-workspace-launch/GOAL.md b/.agents/plans/2026-06-16-workspace-launch/GOAL.md new file mode 100644 index 0000000..6d6f8ca --- /dev/null +++ b/.agents/plans/2026-06-16-workspace-launch/GOAL.md @@ -0,0 +1,18 @@ +# Workspace launch - pasteable goal + +````text +/goal Work in /Users/mg/Developer/outfitter/dispatch. Implement the workspace launch plan in .agents/plans/2026-06-16-workspace-launch/PLAN.md; use WORKSPACE.md for design notes and REFS.md for evidence. Follow AGENTS.md and .agents/plans/PLANNING.md. + +Objective: add a first-class `dispatch new --workspace` preflight layer so worktree-backed lanes can discover repo-local Codex environment metadata, resolve/report the exact effective cwd, and optionally run trusted setup before thread/start. Do not add fake native `--worktree`: prior schema evidence found no App Server worktree request. Repo-local tooling owns environment/worktree semantics; Dispatch owns generic discovery, policy, setup execution when trusted, dry-run JSON, and launch reporting. + +Important current truth: this repo may contain uncommitted Dispatch changes for packet/stage, runtime settings, stale-busy fix, history, and docs. Verify current code before editing; preserve unrelated user/agent changes. If these features are absent because the checkout is stale, stop and report instead of guessing. + +Desired CLI shape: `dispatch new --workspace none|auto| ...`; `none` preserves current behavior; `auto` discovers `.codex/environments/environment.toml`; named presets come from trusted Dispatch config. First environment schema: `version`, `name`, `[setup].script`, `[cleanup].script` as used by Athena and Trails. Packet-local config may request workspace behavior but must not grant setup trust by itself. + +Implementation loop: Phase 1 discovery/dry-run only; Phase 2 trusted setup before launch if policy is clear; Phase 3 explicit git worktree creation only if still needed; Phase 4 docs/skill/smoke. Update RETRO.md before each handoff. Use small Graphite-style phases. Run focused tests after each slice and `just check` before completion. Request local review using repo score/P0-P3 contract; fix P0/P1/P2 or record explicit user acceptance. + +Validation minimum: tests for no metadata no-op, Athena/Trails TOML parsing, invalid TOML before thread creation, dry-run no script execution, `--workspace none` behavior parity, CLI/MCP/schema projection, trusted setup policy, setup failure preventing thread/start, effective cwd/stage path reporting. Any live probe must use isolated DISPATCH_HOME/CODEX_HOME and never the user's live daemon/state. + +Stop rules: stop if App Server now has native worktree fields; stop before trusting packet-local setup execution; stop if setup needs long-running teardown/lifecycle ownership; stop if changes require broad unrelated projection rewrites; stop if dirty user changes make edits unsafe. No merge, publish, non-draft PR, or destructive git action without explicit Matt approval. Completion requires final RETRO.md with commands/results, review state, source-control state, forbidden-action audit, risks, and transcript-visible proof. +```` + diff --git a/.agents/plans/2026-06-16-workspace-launch/PLAN.md b/.agents/plans/2026-06-16-workspace-launch/PLAN.md new file mode 100644 index 0000000..0fef3dd --- /dev/null +++ b/.agents/plans/2026-06-16-workspace-launch/PLAN.md @@ -0,0 +1,236 @@ +# Workspace launch - implementation plan + +Goal-ready plan for adding a first-class workspace preparation layer to +`dispatch new`. Goal loop: [`GOAL.md`](./GOAL.md). Design notes: +[`WORKSPACE.md`](./WORKSPACE.md). References: [`REFS.md`](./REFS.md). Execution +ledger: [`RETRO.md`](./RETRO.md). + +## Objective + +Make worktree-backed `dispatch new` lanes boring and verifiable without +pretending the current Codex App Server has a native worktree request. + +The first product slice should add a small `--workspace` layer that runs before +`thread/start` and resolves the exact cwd Dispatch will pass to the App Server. +It should understand repo-local Codex environment metadata, report what it did +in dry-run and launch JSON, and keep trust-sensitive setup execution explicit +and auditable. + +This is the next step after packet/staging. Packets describe what to ask the +worker to do; workspace launch describes where and how the worker checkout is +prepared. + +## Current Truth To Preserve + +- Previous protocol spike found no native App Server `worktree` request field. + Do not add a fake protocol field or assume a Codex worktree path. +- Dispatch already supports packets, file/stdin launch inputs, output schemas, + staging to `.agents/sessions//`, and stage-only `hooks/`/`codex/`. +- Current working tree also contains runtime-settings persistence for follow-up + sends and a `history` MVP. Before implementing this packet, verify those + changes are present or landed; do not start from a stale checkout silently. +- The main skill/docs now say exact `--cwd` and runtime identity are + authoritative. This packet should turn that doctrine into a launch helper. + +## Product Shape + +Start with: + +```bash +dispatch new --name lane-a --cwd /repo --packet packet --workspace auto --dry-run --json +dispatch new --name lane-a --cwd /repo --packet packet --workspace auto --stage all --json +dispatch new --name lane-a --cwd /repo --packet packet --workspace none +dispatch new --name lane-a --cwd /repo --packet packet --workspace athena +``` + +Recommended semantics: + +- `--workspace none`: preserve current behavior. The effective cwd is the + resolved `--cwd` / config cwd. No environment discovery or setup. +- `--workspace auto`: discover the repo/workspace metadata from cwd. If no + supported metadata is present, degrade to a no-op with a clear JSON reason. +- `--workspace `: load a named workspace preset from Dispatch config. + Presets should be generic and variable-backed, not domain-specific. +- Config should be able to choose the default, for example: + + ```toml + [workspace] + default = "auto" + setup = "trusted-only" + + [workspace.presets.athena] + mode = "auto" + setup = "trusted-only" + ``` + +Names are tentative; keep the implementation smaller than the vocabulary if a +first pass only needs `none` and `auto`. + +## Supported Environment Metadata + +First target: repo-local Codex environment files at: + +```text +.codex/environments/environment.toml +``` + +Observed examples: + +```toml +version = 1 +name = "athena-vault" + +[setup] +script = "./.codex/hooks/workspace-bootstrap.sh" + +[cleanup] +script = "./.codex/hooks/workspace-teardown.sh" +``` + +```toml +version = 1 +name = "trails" + +[setup] +script = "./scripts/bootstrap.sh codex" + +[cleanup] +script = "./scripts/bootstrap.sh teardown" +``` + +Phase 1 should parse `version`, `name`, `[setup].script`, and +`[cleanup].script`. Unknown fields should be preserved in diagnostics but not +executed. + +## Trust And Execution Boundary + +Dispatch must not become a general hook runner that silently executes packet +contents. This feature is different because repo-local environment files are +already the repo's declared Codex workspace setup contract. Even so, execution +must be explicit and auditable. + +Initial policy: + +- `--workspace auto` may discover and report environment metadata by default. +- Setup execution requires trusted local config or an explicit CLI opt-in. Pick + one small policy and document it before implementation. +- Packet-local `dispatch.toml` may request workspace behavior, but it must not + be enough by itself to grant script execution. +- Scripts run from the resolved repo root/workspace root, with a bounded timeout + and captured stdout/stderr summary. +- Cleanup/teardown should be recorded for future work, but do not build a + daemon lifecycle manager in the first slice unless required by setup. + +## Implementation Phases + +### Phase 1 - Workspace discovery and dry-run + +Add pure discovery and preview without executing setup scripts. + +- Add input/model fields for `workspace` on `NewInput` and `LaunchPlan`. +- Create a focused module such as `core/workspace.py`. +- Resolve: + - input cwd; + - git repo root when available; + - environment file path; + - environment name/version; + - setup/cleanup script strings; + - effective launch cwd. +- Add `workspace` block to `new --dry-run --json` and `new` JSON output. +- Keep `--workspace auto` no-op when no metadata exists, with + `state = "not_found"` or similar. +- Tests: + - no metadata means no-op; + - Athena/Trails environment TOML parses; + - invalid TOML fails before thread creation; + - dry-run reports workspace facts and performs no script execution; + - CLI/MCP/schema projections include the field. + +### Phase 2 - Trusted setup execution before launch + +Execute environment setup only when policy allows it. + +- Decide minimal operator policy: + - config-only, for example `[workspace] allow_setup = true`; or + - explicit flag, for example `--workspace-setup run`; or + - both, where CLI can narrow but not widen trust. +- Run setup after launch resolution and before `thread/start`. +- If setup changes the cwd/worktree, update the effective launch cwd from + runtime evidence, not guessed paths. +- Include setup result in JSON: + - script; + - cwd; + - exit code; + - duration; + - stdout/stderr tail; + - effective cwd after setup. +- Failure should be typed and should prevent thread creation unless the mode is + explicitly advisory. +- Tests: + - setup not run without trust; + - trusted setup runs with cwd and timeout; + - setup failure prevents `thread/start`; + - dry-run never runs setup; + - output captures bounded logs. + +### Phase 3 - Worktree creation policy, if still needed + +Only after Phase 1/2 evidence, decide whether Dispatch should create git +worktrees itself. + +Possible shape: + +```bash +dispatch new --workspace auto --worktree create --worktree-name lane-a +``` + +Constraints: + +- Prefer repo-local setup scripts if they already create/select the worktree. +- If Dispatch creates git worktrees, use vanilla `git worktree add` with exact, + visible paths and branch diagnostics. +- Never guess Codex's private worktree location as the default. +- Report branch/head/cwd in dry-run and launch JSON. +- Give clear diagnostics when a branch is already checked out elsewhere. + +This phase can be deferred if `environment.toml` setup gives enough coverage. + +### Phase 4 - Docs, skill, and smoke + +- Update `docs/usage/README.md`, `docs/development/design.md`, + `skills/dispatch/SKILL.md`, and packet docs. +- Add examples for Athena/Trails-style environment files. +- Add `history` guidance for post-launch worktree inspection. +- Run `just check`. +- Run a disposable smoke with isolated `DISPATCH_HOME` and `CODEX_HOME` only if + the executor can do so without touching live user daemon/state. + +## Acceptance Criteria + +- `dispatch new --workspace auto --dry-run --json` reports workspace discovery + facts without mutating daemon, registry, threads, or workspace files. +- `dispatch new --workspace none` exactly preserves current launch behavior. +- Supported `.codex/environments/environment.toml` files parse and are reported. +- Trusted setup execution, if implemented, is impossible from packet-only config + and is visible in JSON output. +- Effective cwd passed to App Server is exact and reported. +- Stage path remains under the effective launch cwd. +- Follow-up docs/skill make clear that Dispatch does not execute packet hooks + and does not assume Codex worktree paths. +- `just check` passes. +- `RETRO.md` is finalized before claiming completion. + +## Stop Rules + +Stop and report if: + +- Current App Server adds native worktree fields that make this plan obsolete; + regenerate schemas and redesign around the native contract. +- Setup execution would require trusting packet-local files without operator + policy. +- Workspace setup needs long-running lifecycle management or teardown ownership + beyond a bounded pre-launch script. +- Implementing this requires broad CLI/MCP projection rewrites unrelated to + `new`. +- Existing uncommitted user changes make it unsafe to edit a target file. + diff --git a/.agents/plans/2026-06-16-workspace-launch/REFS.md b/.agents/plans/2026-06-16-workspace-launch/REFS.md new file mode 100644 index 0000000..2921421 --- /dev/null +++ b/.agents/plans/2026-06-16-workspace-launch/REFS.md @@ -0,0 +1,130 @@ +# Workspace launch - references + +## Repo Guidance + +- `AGENTS.md` + - Author once, derive surfaces. + - App Server access only through `client/`. + - Async core, sync CLI. + - Use repo tasks via `just`. + - Read `docs/development/design.md` and `.agents/plans/v0/PLAN.md` before + implementation. +- `.agents/plans/PLANNING.md` + - One phase = one Graphite branch. + - `just check` is the gate. + - Local review uses score plus P0-P3 findings. + - Update `RETRO.md` before handoff or completion. + +## Prior Packet Work + +- `.agents/plans/2026-06-15-packet-staged-launch/PLAN.md` + - Packet format, `--input-file`, `--goal-file`, `--packet`, `--stage`, + `--inline`, stage-only hooks/config. +- `.agents/plans/2026-06-15-packet-staged-launch/RETRO.md` + - Protocol spike evidence: no native App Server worktree request in + `codex-cli 0.140.0-alpha.2`; `thread/start.config` exists but is raw + passthrough and was intentionally not wired. + +## Current Hot Spots + +- `src/outfitter/dispatch/core/models.py` + - `NewInput`, `NewLane`, `LaunchPlan`, output models. +- `src/outfitter/dispatch/core/launch.py` + - Pure launch resolution for packet/file/inline inputs. +- `src/outfitter/dispatch/core/packet.py` + - Packet `dispatch.toml` safe subset and known files. +- `src/outfitter/dispatch/core/staging.py` + - `.agents/sessions//` staging under launch cwd. +- `src/outfitter/dispatch/core/new_config.py` + - Config/preset/settings merge. +- `src/outfitter/dispatch/core/handlers.py` + - `plan_new_lane`, `new_lane`, `thread/start`, staging, turn start. +- `src/outfitter/dispatch/contracts/derive_cli.py` + - CLI projection and custom `new` handling. +- `src/outfitter/dispatch/contracts/derive_mcp.py` + - MCP grouped-tool projection. +- `tests/core/test_packet.py` +- `tests/core/test_staging.py` +- `tests/core/test_handlers.py` +- `tests/surfaces/test_derive_cli.py` +- `tests/surfaces/test_parity.py` + +## Observed Environment Files + +Athena: + +```toml +version = 1 +name = "athena-vault" + +[setup] +script = "./.codex/hooks/workspace-bootstrap.sh" + +[cleanup] +script = "./.codex/hooks/workspace-teardown.sh" +``` + +Trails: + +```toml +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "trails" + +[setup] +script = "./scripts/bootstrap.sh codex" + +[cleanup] +script = "./scripts/bootstrap.sh teardown" +``` + +Paths checked during planning: + +- `/Users/mg/Developer/athena-vault/.codex/environments/environment.toml` +- `/Users/mg/Developer/outfitter/trails/.codex/environments/environment.toml` + +## Docs To Update + +- `docs/usage/README.md` +- `docs/development/design.md` +- `skills/dispatch/SKILL.md` +- Possibly `plugins/dispatch/README.md` +- Possibly an ADR if setup execution policy becomes durable architecture. + +## Validation Commands + +Focused examples: + +```bash +uv run pytest tests/core/test_handlers.py tests/surfaces/test_derive_cli.py tests/surfaces/test_parity.py -q +uv run pytest tests/core/test_packet.py tests/core/test_staging.py -q +``` + +Gate: + +```bash +just check +``` + +Schema/protocol recheck when needed: + +```bash +CODEX_HOME="$(mktemp -d)" codex app-server generate-json-schema --out "$(mktemp -d)" +CODEX_HOME="$(mktemp -d)" codex app-server generate-json-schema --experimental --out "$(mktemp -d)" +``` + +## Review Contract + +Use the local review shape from `.agents/plans/PLANNING.md`: + +```text +Overall score: n/5 +Summary: +Findings: +- P0|P1|P2|P3 - - + Prompt To Fix With AI: +No-findings statement: +``` + +Fix all P0/P1/P2 before ready/handoff unless Matt explicitly accepts the risk. + diff --git a/.agents/plans/2026-06-16-workspace-launch/RETRO.md b/.agents/plans/2026-06-16-workspace-launch/RETRO.md new file mode 100644 index 0000000..cc643d2 --- /dev/null +++ b/.agents/plans/2026-06-16-workspace-launch/RETRO.md @@ -0,0 +1,187 @@ +# Workspace launch - execution ledger + +Durable execution ledger for workspace launch support. + +## Execution Summary + +- 2026-06-16: Planning packet created from Matt/Codex discussion about + worktree-backed Dispatch lanes, Codex environment files, and `--workspace auto`. +- 2026-06-16: Implemented the first workspace preflight slice in the existing + working tree: `--workspace none|auto|`, environment discovery, dry-run + reporting, explicit/trusted setup execution, docs, and focused tests. +- No branches, commits, PRs, merges, publishes, or tracker updates. + +## Starting Context + +Captured by `context-prime.sh` on 2026-06-16 11:59 EDT: + +- cwd: `/Users/mg/Developer/outfitter/dispatch` +- branch: `main` +- state: `main...origin/main` +- current head: `de56907 Merge attached-lane write policy` +- open PRs for current branch: none +- working tree had uncommitted Dispatch changes for packet-adjacent follow-up + work: runtime settings persistence, stale busy fix, `history`, docs/skill + worktree guidance, and related tests. + +Executor note: verify current repo state before editing. Do not assume the +working tree is clean or that these changes have landed. + +## Branch / PR / Issue Ledger + +- Planning packet only (planner). +- Execution branch: not created by planner. +- PR: none. +- Issues: none created or updated. + +## Tracker Mutations + +- None. + +## Decisions Captured + +- Prefer `--workspace auto|none|` over exposing `--worktree` as if it + were native App Server behavior. +- First-pass environment metadata target: + `.codex/environments/environment.toml`. +- Supported environment v1 fields: `version`, `name`, `[setup].script`, + `[cleanup].script`. +- Discovery/reporting can be automatic; setup execution requires explicit + trusted authority. +- Packet-local config must not grant setup execution trust by itself. +- Effective cwd and stage path must be exact and reported. +- Git worktree creation is a later phase only if repo environment setup does not + cover the need. + +## Execution Log + +### Planning + +- Read `/Users/mg/.agents/skills/goal-planning/SKILL.md`. +- Read `.agents/plans/PLANNING.md`. +- Ran `/Users/mg/.agents/skills/goal-planning/scripts/context-prime.sh`. +- Inspected prior packet-staged launch packet for house style and protocol + findings. +- Inspected current `NewInput`, packet, launch, staging, docs, and tests. +- Checked Athena and Trails `.codex/environments/environment.toml` examples. + +### Implementation + +- Added runtime policy fields for workspace setup trust and timeout. +- Added repo launch config support for `[workspace] default` and + `[workspace.presets.] mode`. +- Added `core/workspace.py` for `.codex/environments/environment.toml` + discovery, environment TOML parsing, setup policy decisions, and bounded setup + execution. +- Wired workspace preflight into `new-plan` and `new` so dry-run and launch JSON + include workspace facts, and thread/start, registry cwd, staging, and initial + turn use the effective cwd. +- Added focused tests for no-op, auto discovery, invalid TOML, named presets, + dry-run no setup, explicit setup, setup failure before thread creation, and + CLI projection. +- Updated operator docs and the Dispatch skill. +- Local self-review found and fixed a runtime policy edge where setting + `DISPATCH_ALLOW_ATTACHED_WRITES` would have bypassed the config file entirely + and dropped workspace setup policy. `runtime_policy()` now reads config first + and applies the env override only to attached writes. +- Added explicit Dispatch-owned git worktree creation via `--worktree create`, + `--worktree-path`, `--worktree-branch`, and `--worktree-base`. The default root + is `~/.dispatch/worktrees///` (or `DISPATCH_WORKTREE_ROOT`), not + repo-local `.dispatch/worktrees/` and not Claude/Codex private path schemes. +- Added workspace config support for worktree defaults in repo + `.dispatch/config.toml`: `[workspace] worktree/worktree_path/worktree_branch/ + worktree_base` and matching `[workspace.presets.]` overrides. Explicit + CLI worktree flags still win. + +## Verification Log + +- `context-prime.sh` completed. +- Plan packet written under `.agents/plans/2026-06-16-workspace-launch/`. +- Focused tests: + `uv run pytest tests/core/test_workspace.py tests/core/test_new_config.py tests/core/test_handlers.py::test_plan_new_lane_reports_workspace_without_setup tests/core/test_handlers.py::test_new_lane_workspace_auto_uses_effective_repo_cwd tests/core/test_handlers.py::test_new_lane_workspace_setup_requires_policy_or_explicit_run tests/core/test_handlers.py::test_new_lane_workspace_setup_runs_with_explicit_run tests/core/test_handlers.py::test_new_lane_workspace_setup_failure_prevents_thread_start tests/surfaces/test_derive_cli.py::test_new_stage_and_inline_pass_through tests/surfaces/test_derive_cli.py::test_new_json_output_includes_staged_summary -q` + → passed, 19 passed. +- Broader focused suite: + `uv run pytest tests/core/test_examples.py tests/core/test_workspace.py tests/core/test_handlers.py tests/surfaces/test_derive_cli.py tests/surfaces/test_parity.py tests/test_config.py tests/core/test_new_config.py -q` + → passed, 132 passed. +- Full gate: + `just check` → passed: ruff check, ruff format --check, mypy, pytest + (306 passed, 9 deselected), build, and package contents check. +- Full gate after review fix: + `just check` → passed: ruff check, ruff format --check, mypy, pytest + (306 passed, 9 deselected), build, and package contents check. +- Worktree-focused tests: + `uv run pytest tests/core/test_workspace.py tests/core/test_handlers.py::test_new_lane_dispatch_created_worktree_is_effective_cwd_for_stage_and_thread tests/surfaces/test_derive_cli.py::test_new_stage_and_inline_pass_through tests/core/test_examples.py -q` + → passed, 14 passed. +- Full gate after Dispatch-owned worktree creation: + `just check` → passed: ruff check, ruff format --check, mypy, pytest + (310 passed, 9 deselected), build, and package contents check. +- Focused workspace-config extension checks: + `uv run pytest tests/core/test_workspace.py tests/core/test_new_config.py tests/surfaces/test_derive_cli.py::test_new_stage_and_inline_pass_through -q` + → passed, 19 passed. +- Focused typecheck: + `uv run mypy --strict src/outfitter/dispatch/core/workspace.py src/outfitter/dispatch/core/new_config.py src/outfitter/dispatch/core/models.py` + → passed. +- `git diff --check` → passed. +- Full gate after workspace worktree config support: + `just check` → passed: ruff check, ruff format --check, mypy, pytest + (313 passed, 9 deselected), build, and package contents check. +- Version bump: + `pyproject.toml` and `uv.lock` updated from `0.6.1` to `0.7.0`. +- Full gate after `0.7.0` version bump: + `just check` → passed: ruff check, ruff format --check, mypy, pytest + (313 passed, 9 deselected), build, and package contents check. Built + `dist/outfitter_dispatch-0.7.0.tar.gz` and + `dist/outfitter_dispatch-0.7.0-py3-none-any.whl`. + +## Local Review Log + +- Local self-review, 2026-06-16: + - Overall score: 4/5. + - Summary: Workspace slice is scoped and tested; no live daemon smoke was run. + - Finding fixed: P2 - `src/outfitter/dispatch/config.py` - attached-write env + override bypassed the rest of the runtime policy file, which would make + workspace setup policy unexpectedly disappear when the env var is present. + - Prompt To Fix With AI: Read config first, then apply + `DISPATCH_ALLOW_ATTACHED_WRITES` only to `allow_attached_writes`; preserve + workspace setup fields and add a regression test. + - Residual risk: setup scripts execute through the shell when explicitly + trusted/requested; docs and policy make that boundary visible, but no live + Athena/Trails smoke was run. +- Local direction correction, 2026-06-16: + - Matt clarified that Dispatch-created worktrees should not live under + repo-local `.dispatch/worktrees/`. + - Local path inspection showed multiple Claude/Codex private worktree layouts, + including short-id and generated-name schemes that should not be treated as + a stable public contract. + - Implementation adjusted to default to `~/.dispatch/worktrees///` + with `DISPATCH_WORKTREE_ROOT` and `--worktree-path` overrides. + - Follow-up clarified that Dispatch now respects its own repo-local workspace + definition for worktree defaults, but does not parse undocumented Codex + private worktree configuration. + +## Remote Review / CI Log + +- No remote review requested. +- No CI run. + +## Forbidden Actions Audit + +- No implementation. +- No branch creation. +- No commit. +- No PR. +- No merge. +- No publish. +- No tracker mutation. +- No live Dispatch/Codex daemon mutation. + +## Final State + +- Status: workspace + Dispatch-owned git worktree implementation complete in the + working tree; package version bumped to `0.7.0`; not committed. +- Remaining risk: no live App Server smoke was run. The implementation is + covered by unit/projection tests and does not depend on native worktree + protocol support. +- Next suggested follow-up: live smoke `dispatch new --workspace auto --worktree + create --stage all` against a disposable repo or Athena smoke packet with + isolated `DISPATCH_HOME`/`CODEX_HOME`. diff --git a/.agents/plans/2026-06-16-workspace-launch/WORKSPACE.md b/.agents/plans/2026-06-16-workspace-launch/WORKSPACE.md new file mode 100644 index 0000000..e57c1c6 --- /dev/null +++ b/.agents/plans/2026-06-16-workspace-launch/WORKSPACE.md @@ -0,0 +1,165 @@ +# Workspace Launch Design Notes + +This document records the intended shape of Dispatch workspace launch support. +It is not an ADR yet; promote durable decisions after the implementation proves +the shape. + +## Why This Exists + +Parallel worker lanes often need more than a prompt. They need the right +checkout, repo bootstrap, generated helper files, and output directories before +the first turn. Packet staging solved "what should the worker read". Workspace +launch should solve "where should the worker run, and was that environment +prepared". + +The feature should reduce ceremony for coordinators while keeping Dispatch out +of domain workflows. Repo-local tooling still owns what setup means. + +## Core Model + +Workspace launch is a preflight layer for `dispatch new`: + +1. Resolve launch inputs and packet/config settings. +2. Resolve workspace mode from CLI/config. +3. Discover repo/workspace metadata from the input cwd. +4. Optionally run trusted setup. +5. Compute the exact effective cwd. +6. Call `thread/start` with that cwd. +7. Stage packet files under that same effective cwd. +8. Return JSON that explains every workspace decision. + +The effective cwd is a value, not a guess. If setup or a future native Codex +feature returns a different cwd, Dispatch reports and uses that returned cwd. + +## Terminology + +- Workspace: the checkout/environment Dispatch launches into. +- Environment file: repo-local `.codex/environments/environment.toml`. +- Setup script: bounded pre-launch script declared by the environment file. +- Cleanup script: declared teardown command, recorded but not necessarily owned + by Dispatch in the first implementation. +- Worktree: a git checkout. It may be managed by Codex, repo scripts, or a + future Dispatch helper. + +## JSON Shape Sketch + +Dry-run and launch output should include something like: + +```json +{ + "workspace": { + "mode": "auto", + "state": "discovered", + "input_cwd": "/repo", + "repo_root": "/repo", + "effective_cwd": "/repo", + "environment_file": "/repo/.codex/environments/environment.toml", + "environment": { + "version": 1, + "name": "athena-vault", + "setup_script": "./.codex/hooks/workspace-bootstrap.sh", + "cleanup_script": "./.codex/hooks/workspace-teardown.sh" + }, + "setup": { + "policy": "not_allowed", + "ran": false + } + } +} +``` + +If setup runs: + +```json +{ + "setup": { + "policy": "trusted", + "ran": true, + "script": "./scripts/bootstrap.sh codex", + "cwd": "/repo", + "exit_code": 0, + "duration_ms": 827, + "stdout_tail": "...", + "stderr_tail": "", + "effective_cwd": "/repo" + } +} +``` + +Keep logs bounded. Do not leak secrets if scripts print them; prefer tails and +document the risk. + +## Config Sketch + +Possible minimal config: + +```toml +[workspace] +default = "auto" +allow_setup = false +setup_timeout_seconds = 120 + +[workspace.presets.athena] +mode = "auto" +allow_setup = true + +[workspace.presets.noop] +mode = "none" +``` + +Rules: + +- CLI `--workspace none` always disables discovery/setup. +- CLI `--workspace auto` enables discovery. It should not by itself grant setup + execution unless that is the explicit final product decision. +- Named presets come from trusted Dispatch config. +- Packet-local config can request a workspace mode, but cannot grant setup + trust. + +## Environment File v1 + +Supported first-pass schema: + +```toml +version = 1 +name = "repo-name" + +[setup] +script = "./relative-or-shell-command" + +[cleanup] +script = "./relative-or-shell-command" +``` + +Execution policy details to decide during implementation: + +- Use shell or argv splitting. Shell is compatible with Trails + `"./scripts/bootstrap.sh codex"` but has quoting/trust implications. +- Run from repo root or environment-file parent. Prefer repo root because both + observed examples use repo-relative paths. +- Bound timeout and captured output. +- Decide whether cleanup is manual, future trigger-owned, or recorded only. + +## Relationship To `--worktree` + +Do not expose `--worktree` as native Codex behavior until the App Server has a +native worktree contract. If Dispatch later adds worktree creation, it should be +plain git setup before launch, clearly reported as Dispatch-created, and not +hidden behind Codex terminology. + +Likely sequence: + +1. Ship `--workspace auto` discovery/reporting. +2. Ship trusted setup execution. +3. Evaluate whether repo setup scripts cover worktree creation. +4. Add a small explicit git worktree helper only if needed. + +## Open Questions + +- Should setup execution be config-only, CLI-only, or require both? +- Should `auto` be the default immediately, or should config choose it? +- Should cleanup be a recorded command, a future trigger, or intentionally + outside Dispatch? +- Should workspace presets live in `.dispatch/config.toml`, + `~/.dispatch/config.toml`, or both with local/global precedence? + diff --git a/docs/development/design.md b/docs/development/design.md index 6b7c308..80f857d 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -78,10 +78,15 @@ Projections (pure functions over the registry, mirroring Trails' `derive* → cr · launch packets/files: `[--packet DIR] [--goal-file F|-] [--input-file F|-] [--output-schema-file F]` · preview: `[--dry-run]` · staging: `[--stage all|] [--inline ]` (writes `.agents/sessions//`; - `new --dry-run` → mutation-free `new-plan` op). No `--worktree`: no native App - Server worktree request exists. + `new --dry-run` → mutation-free `new-plan` op) · workspace preflight: + `[--workspace none|auto|] [--workspace-setup auto|skip|run]` + `[--worktree none|create] [--worktree-path PATH] [--worktree-branch BRANCH] + [--worktree-base REF]`. No native App Server worktree request exists; + `--worktree create` is Dispatch-owned vanilla git worktree setup under + `~/.dispatch/worktrees/` by default, with exact cwd/branch/head reported. - Thread reads/discovery: `get ` · `list` · `list --unmanaged` · - `sync ` · `tail ` · `watch ` + `sync ` · `tail ` · `history [selector]` · + `watch ` - Thread management/search: `attach [--sync]` · `rename ` · `archive ` · `restore ` · `search ` with `--thread`/repo/directory/date/managed filters @@ -129,6 +134,7 @@ for collisions. Titles and `@handles` are mutable convenience labels. | `models` | `config/read` + optional `model/list` | Reports current Codex model defaults and the App Server model catalog, including service-tier aliases such as user-facing `fast` to server-facing ids like `priority`. `--no-refresh` reads the registry cache plus current config defaults. | | `show` (`get`) | registry + optional `thread/read(includeTurns:true)` | Compact managed-thread summary with sync state and latest observed turn runtime/error state; optional transcript convenience. | | `transcript` (`tail`) | `thread/read(includeTurns:true)` | Persisted turn/item snapshot, not a full execution log. | +| `history` (`history`) | `thread/read(includeTurns:true)` + registry/sync facts | Transcript intelligence surface. Bare `dispatch history` summarizes managed lanes; `dispatch history ` reports per-thread summary/tools/files/items with optional filters and best-effort worktree facts. | | `watch` (`watch`) | raw app-server event stream, bounded by limit/timeout | Request/response bounded sample; a true infinite tail needs a subscription control-socket extension. | | `goal-get/set/clear` (`goal status/set/clear`) | `thread/goal/{get,set,clear}` | Native App Server goal lifecycle for owned lanes. | | `fork` | `thread/fork` + register | Creates a new owned lane; attached source lanes remain locked until cross-process fork semantics are verified. | @@ -175,6 +181,7 @@ The client supports the full responder loop. v1 surfaces `waiting_on_approval` a - `lane_snapshots`: lane, display name, preview, cwd, source/model/session facts, latest event timestamp, latest turn id, transcript-partial flag. - `model_catalog`: provider/model rows refreshed from App Server `model/list`, including reasoning efforts, service tiers, aliases, and first/last seen timestamps. - `lane_model_settings`: per-lane model/provider/reasoning/service-tier provenance, distinguishing Dispatch-authored settings from configured defaults and observed metadata. +- `lane_runtime_settings`: per-lane turn-start defaults such as sandbox, approval policy, reviewer, model/effort/tier, structured output schema, and personality; follow-up sends reuse these settings. - `queued_messages`: lane, text, delivery status, timestamps, and error for durable `send --queue` delivery; rows are tied to lanes and cascade with lane deletion. - `triggers`: id, name, lane selector, when-spec (json), action-spec (json), guard-spec (json), enabled, last_fired_at. - `actions_log`: id, ts, lane, op, trigger_id?, request/decision, outcome — full audit of every send/action. diff --git a/docs/usage/README.md b/docs/usage/README.md index 03be19d..5246951 100644 --- a/docs/usage/README.md +++ b/docs/usage/README.md @@ -335,8 +335,106 @@ left registered and marked `error`, and the first turn does not start. Dispatch **stages** `hooks/` and `codex/` files but never executes hooks or applies Codex config — execution and trust remain Codex's authority. The current App Server -has no native worktree request, so there is no `--worktree` flag; Dispatch does not -guess worktree paths. +has no native worktree request; Dispatch's `--worktree create` helper is a vanilla +git preflight, not a Codex protocol feature. + +For worktree-backed lanes, pass the exact `--cwd` you want Dispatch to launch in +and treat the runtime checkout as authoritative. Do not depend on a fixed Codex +worktree layout such as `.codex/worktrees//` or +`~/.config/codex/worktrees/`; Codex-managed worktrees may be detached or +unnamed, and an empty `git branch --show-current` is not automatically a failure. +Verify identity with `pwd`, `git rev-parse --show-toplevel`, +`git rev-parse --short HEAD`, `git status --short`, and any repo-provided runtime +or workspace doctor command. If a repo provides `.codex/environments/environment.toml`, +setup/teardown hooks, or bootstrap scripts, repo-local tooling owns those +semantics; Dispatch only stages files and reports the lane/ref/cwd facts. + +### Workspace Preflight + +Use `--workspace` when you want Dispatch to resolve repo-local workspace metadata before +creating the thread: + +```bash +uv run dispatch new --name lane-a --cwd /repo --packet ./packet --workspace auto --dry-run --json +uv run dispatch new --name lane-a --cwd /repo --packet ./packet --workspace auto --stage all --json +uv run dispatch new --name lane-a --cwd /repo --packet ./packet --workspace none +``` + +`--workspace none` preserves the normal exact-`--cwd` launch path. `--workspace auto` +looks for `.codex/environments/environment.toml` from the launch cwd/repo root and +reports the environment name, version, setup script, cleanup script, repo root, and +effective cwd. If no supported metadata exists, it is a no-op with `state: +"not_found"` in JSON output. + +Use `--worktree create` when Dispatch should create a vanilla git worktree before +launch: + +```bash +uv run dispatch new --name lane-a --cwd /repo --worktree create --dry-run --json +uv run dispatch new --name lane-a --cwd /repo --worktree create --worktree-branch dispatch/lane-a --json +uv run dispatch new --name lane-a --cwd /repo --worktree create --worktree-path /tmp/lane-a +``` + +By default, Dispatch-created worktrees live under +`~/.dispatch/worktrees///`. Override the root with +`DISPATCH_WORKTREE_ROOT` or pass an explicit `--worktree-path`. Dispatch does not use +repo-local `.dispatch/worktrees/` by default, and it does not mimic Claude/Codex +private worktree layouts. The JSON output reports the exact worktree path, branch, +base ref, source repo, and head used for the branch. + +Worktree defaults can live in repo `.dispatch/config.toml` workspace settings, with +CLI flags winning: + +```toml +[workspace] +default = "auto" +worktree = "create" +worktree_branch = "dispatch/default" +worktree_base = "HEAD" + +[workspace.presets.athena] +mode = "auto" +worktree = "create" +worktree_branch = "dispatch/athena" +``` + +Relative `worktree_path` values in `.dispatch/config.toml` resolve from the repo +configuration root. Prefer the default global root or explicit absolute paths for +cross-machine packets. + +Default branch naming is `dispatch/`. If the branch is already checked +out in another worktree, launch fails before thread creation with the owning +worktree path. If the branch does not exist, Dispatch creates it from +`--worktree-base` (default `HEAD`); if it exists and is not checked out elsewhere, +Dispatch checks it out in the new worktree. + +The first supported environment file shape is: + +```toml +version = 1 +name = "repo-name" + +[setup] +script = "./scripts/bootstrap.sh codex" + +[cleanup] +script = "./scripts/bootstrap.sh teardown" +``` + +Discovery is automatic, but setup execution is not granted by packet files. A setup +script runs only when explicitly requested with `--workspace-setup run` or allowed by +local daemon policy: + +```toml +[policy] +allow_workspace_setup = true +workspace_setup_timeout_seconds = 120 +``` + +Setup runs before `thread/start`; a failing or timed-out setup prevents thread +creation. Dry runs never execute setup. Launch JSON includes bounded stdout/stderr +tails so operators can see what happened without treating Dispatch as a full +workspace lifecycle manager. Use `send --context` for silent context injection. It adds model-visible context without starting a turn: @@ -400,6 +498,20 @@ full execution log. App Server does not support `includeTurns` on ephemeral thre uv run dispatch tail --limit 50 ``` +Use `history` when you want transcript inspection and rollups rather than only recent +items. Bare `history` summarizes managed lanes; passing a selector drills into one +thread and can show summary, items, tools, or files. `--type`, `--tool`, and `--grep` +filter item views; `--raw` includes raw App Server item payloads for jq-heavy +inspection. + +```bash +uv run dispatch history +uv run dispatch history +uv run dispatch history --view tools +uv run dispatch history --view files +uv run dispatch history --view items --tool bash --grep "git status" --raw +``` + Use `watch` for a bounded live event sample from dispatch's app-server stream. It returns raw App Server method names and params for the selected lane until `--limit` events arrive or `--timeout` elapses. It is intentionally bounded because the current diff --git a/pyproject.toml b/pyproject.toml index e5e6901..9bb647e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "outfitter-dispatch" -version = "0.6.1" +version = "0.7.0" description = "Local control plane for orchestrating Codex agent lanes over the Codex App Server." readme = "README.md" requires-python = ">=3.13" diff --git a/skills/dispatch/SKILL.md b/skills/dispatch/SKILL.md index 04fe371..3b71b00 100644 --- a/skills/dispatch/SKILL.md +++ b/skills/dispatch/SKILL.md @@ -30,7 +30,7 @@ The current canonical operator grammar is: - registry recovery: `registry migrate` - model catalog: `models` - thread lifecycle/read/search: `new`, `attach`, `list`, `list --unmanaged`, - `get`, `sync`, `tail`, `watch`, `search` + `get`, `sync`, `tail`, `history`, `watch`, `search` - thread actions: `rename`, `archive`, `restore` - message verbs: `send`, `stop` - goals: `goal status`, `goal set`, `goal clear` @@ -108,8 +108,73 @@ read stdin (`-`). `--dry-run` resolves and prints the plan (sources with byte/SHA-256, effective settings, staged parts) without mutating any state. `--stage all|` writes durable twins to `.agents/sessions//` (with `--inline ` to exclude -some); dispatch stages `hooks/`/`codex/` but never executes hooks. There is no -`--worktree`: the current App Server exposes no native worktree request. +some); dispatch stages `hooks/`/`codex/` but never executes hooks. The current +App Server exposes no native worktree request; Dispatch's `--worktree create` +helper is a vanilla git preflight, not a Codex protocol feature. + +For worktree-backed lanes, treat the launched runtime as the source of truth. +Dispatch should be given the exact `--cwd`; it should not assume fixed Codex +worktree paths such as `.codex/worktrees//` or +`~/.config/codex/worktrees/`. Codex-managed worktrees may be detached or +unnamed, so an empty `git branch --show-current` is not automatically a failure. +Verify identity with `pwd`, `git rev-parse --show-toplevel`, +`git rev-parse --short HEAD`, `git status --short`, and any repo-provided runtime +or workspace doctor command. A branch name is useful metadata, not proof of +correctness unless the coordinator explicitly required a named branch. + +If the repo provides `.codex/environments/environment.toml`, setup/teardown +hooks, or workspace bootstrap scripts, let repo-local tooling own those +semantics. Dispatch may stage the packet, hook files, and Codex config files so +the lane can inspect or run them, but Dispatch should not execute arbitrary hooks +or apply trust-sensitive config on the repo's behalf. + +Use `--workspace` when Dispatch should resolve repo-local workspace metadata +before creating the thread: + +```bash +uv run dispatch new --name lane-a --cwd /repo --packet ./packet --workspace auto --dry-run --json +uv run dispatch new --name lane-a --cwd /repo --packet ./packet --workspace auto --stage all +uv run dispatch new --name lane-a --cwd /repo --packet ./packet --workspace none +``` + +`--workspace none` preserves the exact cwd path. `--workspace auto` discovers +`.codex/environments/environment.toml`, reports environment name/version, +setup/cleanup scripts, repo root, and effective cwd, and no-ops with +`state="not_found"` when no supported metadata exists. Dry runs never execute +setup. Setup scripts run only with explicit `--workspace-setup run` or local +daemon policy `[policy] allow_workspace_setup = true`; packet-local config is +not enough to grant setup execution. + +Use `--worktree create` when Dispatch should create a vanilla git worktree before +launch: + +```bash +uv run dispatch new --name lane-a --cwd /repo --worktree create --dry-run --json +uv run dispatch new --name lane-a --cwd /repo --worktree create --worktree-branch dispatch/lane-a +uv run dispatch new --name lane-a --cwd /repo --worktree create --worktree-path /tmp/lane-a +``` + +The default root is `~/.dispatch/worktrees///`, not a repo-local +`.dispatch/worktrees/` directory. `DISPATCH_WORKTREE_ROOT` can override the root. +Do not assume or mimic Claude/Codex private worktree path schemes; Dispatch +reports the exact path/branch/base/head it created. If a branch is already +checked out elsewhere, launch fails before thread creation and names the owning +worktree. + +Workspace config can carry worktree defaults, with CLI flags winning: + +```toml +[workspace] +default = "auto" +worktree = "create" +worktree_branch = "dispatch/default" +worktree_base = "HEAD" + +[workspace.presets.athena] +mode = "auto" +worktree = "create" +worktree_branch = "dispatch/athena" +``` `new` returns `message_accepted`, not proof of assistant completion. After launch, use `get` to check `latest_turn`, `tail` for persisted history, or `watch` for a @@ -271,6 +336,18 @@ uv run dispatch tail --limit 50 `tail` uses App Server `includeTurns`, which is not available for ephemeral threads. +Use `history` for transcript inspection and rollups. Bare `history` summarizes +managed lanes; passing a selector drills into one thread and can show summary, +items, tools, or files: + +```bash +uv run dispatch history +uv run dispatch history +uv run dispatch history --view tools +uv run dispatch history --view files +uv run dispatch history --view items --tool bash --grep "git status" --raw +``` + Use `watch` for a bounded live event sample. It returns raw App Server method/params until a limit or timeout, and it is not an infinite tail: diff --git a/src/outfitter/dispatch/config.py b/src/outfitter/dispatch/config.py index 653da19..17d36c9 100644 --- a/src/outfitter/dispatch/config.py +++ b/src/outfitter/dispatch/config.py @@ -33,6 +33,12 @@ def pidfile_path() -> Path: return Path(override) if override else _base() / "dispatchd.pid" +def worktree_root_path() -> Path: + """Default root for Dispatch-created git worktrees.""" + override = os.environ.get("DISPATCH_WORKTREE_ROOT") + return Path(override).expanduser() if override else _base() / "worktrees" + + def config_path() -> Path: """Local dispatch daemon config.""" override = os.environ.get("DISPATCH_CONFIG") @@ -55,6 +61,8 @@ class RuntimePolicy: """ allow_attached_writes: bool = False + allow_workspace_setup: bool = False + workspace_setup_timeout_seconds: int = 120 def runtime_policy() -> RuntimePolicy: @@ -63,21 +71,42 @@ def runtime_policy() -> RuntimePolicy: Env wins so test and one-shot operator shells can force policy without mutating the user's config file. """ - value = os.environ.get("DISPATCH_ALLOW_ATTACHED_WRITES") - if value is not None: - return RuntimePolicy(allow_attached_writes=_truthy(value)) - path = config_path() + policy = RuntimePolicy() if not path.exists(): - return RuntimePolicy() + return _apply_env_policy(policy) with path.open("rb") as f: raw = tomllib.load(f) - policy = raw.get("policy", raw) - if not isinstance(policy, dict): - return RuntimePolicy() - return RuntimePolicy(allow_attached_writes=bool(policy.get("allow_attached_writes", False))) + raw_policy = raw.get("policy", raw) + if not isinstance(raw_policy, dict): + return _apply_env_policy(policy) + policy = RuntimePolicy( + allow_attached_writes=bool(raw_policy.get("allow_attached_writes", False)), + allow_workspace_setup=bool(raw_policy.get("allow_workspace_setup", False)), + workspace_setup_timeout_seconds=_positive_int( + raw_policy.get("workspace_setup_timeout_seconds"), default=120 + ), + ) + return _apply_env_policy(policy) def _truthy(value: str) -> bool: return value.strip().lower() in {"1", "true", "yes", "on"} + + +def _apply_env_policy(policy: RuntimePolicy) -> RuntimePolicy: + attached = os.environ.get("DISPATCH_ALLOW_ATTACHED_WRITES") + if attached is None: + return policy + return RuntimePolicy( + allow_attached_writes=_truthy(attached), + allow_workspace_setup=policy.allow_workspace_setup, + workspace_setup_timeout_seconds=policy.workspace_setup_timeout_seconds, + ) + + +def _positive_int(value: object, *, default: int) -> int: + if isinstance(value, int) and value > 0: + return value + return default diff --git a/src/outfitter/dispatch/contracts/derive_cli.py b/src/outfitter/dispatch/contracts/derive_cli.py index fec92a0..8360ef4 100644 --- a/src/outfitter/dispatch/contracts/derive_cli.py +++ b/src/outfitter/dispatch/contracts/derive_cli.py @@ -28,6 +28,7 @@ _SendMode = Literal["send", "steer", "queue", "interject", "context"] _SearchSortKey = Literal["created_at", "updated_at"] +_HistoryView = Literal["auto", "overview", "summary", "items", "tools", "files"] @dataclass(frozen=True) @@ -42,6 +43,7 @@ class CliRoute: CliRoute(("send",), "send", ("lane", "text")), CliRoute(("stop",), "stop", ("lane",)), CliRoute(("search",), "search", ("query",)), + CliRoute(("history",), "history"), CliRoute(("list",), "roster"), CliRoute(("trigger", "list"), "trigger-list"), CliRoute(("goal", "set"), "goal-set", ("lane", "objective")), @@ -103,6 +105,9 @@ 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, ("history",), _history_command(registry.get("history"), invoke, renderer) + ) _register_command(app, ("list",), _list_command(registry, invoke, renderer)) _register_command( app, @@ -243,6 +248,7 @@ def _parameters(op: Op, *, positionals: tuple[str, ...] = ()) -> list[inspect.Pa "output_schema_file", "base_file", "developer_file", + "worktree_path", ) @@ -491,6 +497,47 @@ def _invoke_search( _ignore_json(json) +def _history_command(op: Op, invoke: Invoker, render: Renderer) -> Callable[..., None]: + def command( + lane: Annotated[ + str | None, + typer.Argument(help="Optional thread selector. Omit for overview."), + ] = None, + view: Annotated[_HistoryView, typer.Option("--view", help="History view.")] = "auto", + item_type: Annotated[ + str | None, typer.Option("--type", help="Only include matching item types.") + ] = None, + tool: Annotated[ + str | None, typer.Option("--tool", help="Only include matching tool names.") + ] = None, + grep: Annotated[ + str | None, typer.Option("--grep", help="Only include items containing text.") + ] = None, + raw: Annotated[bool, typer.Option("--raw", help="Include raw item payloads.")] = False, + limit: Annotated[int, typer.Option("--limit", help="Max rows/items to return.")] = 50, + json: Annotated[ + bool, typer.Option("--json", help="Render machine-readable JSON output.") + ] = False, + ) -> None: + result = invoke( + op.id, + { + "lane": lane, + "view": view, + "item_type": item_type, + "tool": tool, + "grep": grep, + "raw": raw, + "limit": limit, + }, + ) + render(op, result) + _ignore_json(json) + + command.__doc__ = op.summary + return command + + def _list_command(registry: OpRegistry, invoke: Invoker, render: Renderer) -> Callable[..., None]: roster = registry.get("roster") discover = registry.get("discover") diff --git a/src/outfitter/dispatch/contracts/derive_mcp.py b/src/outfitter/dispatch/contracts/derive_mcp.py index e60fac1..f693678 100644 --- a/src/outfitter/dispatch/contracts/derive_mcp.py +++ b/src/outfitter/dispatch/contracts/derive_mcp.py @@ -52,6 +52,7 @@ class _ToolGroup: ("new_plan", "new-plan"), ("show", "show"), ("transcript", "transcript"), + ("history", "history"), ("watch", "watch"), ("goal_get", "goal-get"), ("search", "search"), diff --git a/src/outfitter/dispatch/core/handlers.py b/src/outfitter/dispatch/core/handlers.py index 1308f60..eb7815a 100644 --- a/src/outfitter/dispatch/core/handlers.py +++ b/src/outfitter/dispatch/core/handlers.py @@ -20,11 +20,9 @@ from outfitter.dispatch.client.errors import AppServerError as ClientAppServerError from outfitter.dispatch.client.errors import ClientError from outfitter.dispatch.client.models import ( - SandboxPolicy, ThreadGoal, ThreadInfo, ThreadResult, - ThreadSandbox, ) from outfitter.dispatch.contracts.context import Ctx from outfitter.dispatch.contracts.errors import ( @@ -48,6 +46,7 @@ ) from . import queue +from .history import detect_worktree, history_items_from_thread, summarize_history from .launch import ResolvedLaunch, resolve_launch from .model_registry import refresh_model_catalog, resolve_model_settings from .models import ( @@ -64,6 +63,12 @@ GoalGetInput, GoalSetInput, GoalView, + HistoryFileStat, + HistoryInput, + HistoryItem, + HistoryOutput, + HistoryThreadSummary, + HistoryToolStat, LaneCapabilities, LaneDetail, LaneInput, @@ -114,8 +119,13 @@ from .selectors import resolve_managed_selector, resolve_thread_selector from .staging import StageContent, stage_session from .sync import scan_codex_jsonl +from .turn_settings import ( + load_turn_start_settings, + runtime_settings_for_lane, + thread_sandbox_to_turn_policy, +) +from .workspace import plan_workspace, prepare_workspace -_READ_ONLY = SandboxPolicy(type="readOnly") _INTRO_TEMPLATE = '[dispatch] From {handle} ({ref}). Use `dispatch send {ref} "..."` to reply.' # Bound attach metadata reads: if the app-server is wedged, fail clearly and never @@ -398,6 +408,9 @@ async def open_lane(inp: OpenInput, ctx: Ctx) -> LaneRef: lane = await ctx.registry.add_lane( id=thread.id, handle=handle, source="own", cwd=inp.cwd, status="idle" ) + await ctx.registry.upsert_lane_runtime_settings( + runtime_settings_for_lane(lane=lane.id, updated_at=ctx.registry.now_iso()) + ) await ctx.registry.log_action("open", lane=lane.id, detail=handle) ctx.log.info("lane.open", lane=lane.id, handle=handle) return _ref(lane, ctx) @@ -438,11 +451,24 @@ async def plan_new_lane(inp: NewInput, ctx: Ctx) -> LaunchPlan: """Resolve a launch and report what it would do — no daemon/thread mutation.""" launch = resolve_launch(inp) _validate_launch(launch) + workspace = plan_workspace( + cwd=launch.resolved.cwd, + name=launch.resolved.display_name, + requested=inp.workspace, + setup=inp.workspace_setup, + worktree=inp.worktree, + worktree_path=inp.worktree_path, + worktree_branch=inp.worktree_branch, + worktree_base=inp.worktree_base, + config=launch.resolved.workspace, + policy=ctx.policy, + ) s = launch.resolved.settings return LaunchPlan( name=launch.resolved.display_name, handle=launch.resolved.handle, - cwd=str(launch.resolved.cwd), + cwd=str(workspace.effective_cwd), + workspace=workspace.view, packet=_packet_str(launch), settings=LaunchSettingsView( sandbox=s.sandbox, @@ -481,6 +507,19 @@ async def new_lane(inp: NewInput, ctx: Ctx) -> NewLane: resolved = launch.resolved settings = resolved.settings goal = launch.goal + workspace = await prepare_workspace( + cwd=resolved.cwd, + name=resolved.display_name, + requested=inp.workspace, + setup=inp.workspace_setup, + worktree=inp.worktree, + worktree_path=inp.worktree_path, + worktree_branch=inp.worktree_branch, + worktree_base=inp.worktree_base, + config=resolved.workspace, + policy=ctx.policy, + ) + effective_cwd = workspace.effective_cwd sandbox = settings.sandbox or "read-only" approval_policy = settings.approval_policy or "never" resolved_model = await resolve_model_settings( @@ -492,7 +531,7 @@ async def new_lane(inp: NewInput, ctx: Ctx) -> NewLane: ) explicit_service_tier = resolved_model.resolved_service_tier if settings.service_tier else None thread = await ctx.client.thread_start( - cwd=str(resolved.cwd), + cwd=str(effective_cwd), sandbox=sandbox, approval_policy=approval_policy, approvals_reviewer=settings.approvals_reviewer, @@ -505,10 +544,25 @@ async def new_lane(inp: NewInput, ctx: Ctx) -> NewLane: ephemeral=bool(settings.ephemeral), ) lane = await ctx.registry.add_lane( - id=thread.id, handle=resolved.handle, source="own", cwd=str(resolved.cwd), status="idle" + id=thread.id, handle=resolved.handle, source="own", cwd=str(effective_cwd), status="idle" ) lane_model = resolved_model.for_lane(lane.id, ctx.registry.now_iso()) await ctx.registry.upsert_lane_model_settings(lane_model) + await ctx.registry.upsert_lane_runtime_settings( + runtime_settings_for_lane( + lane=lane.id, + updated_at=ctx.registry.now_iso(), + sandbox=sandbox, + approval_policy=approval_policy, + approvals_reviewer=settings.approvals_reviewer, + effort=settings.effort, + summary=settings.summary, + model=settings.model, + service_tier=explicit_service_tier, + output_schema=settings.output_schema, + personality=settings.personality, + ) + ) await ctx.registry.log_action("new", lane=lane.id, detail=resolved.display_name) try: await ctx.client.thread_set_name(thread.id, resolved.display_name) @@ -535,7 +589,7 @@ async def new_lane(inp: NewInput, ctx: Ctx) -> NewLane: try: result = await asyncio.to_thread( stage_session, - cwd=resolved.cwd, + cwd=effective_cwd, ref=lane.ref, lane_id=lane.id, plan=launch.stage_plan, @@ -567,13 +621,14 @@ async def new_lane(inp: NewInput, ctx: Ctx) -> NewLane: message_accepted = False if settings.text is not None and inp.send: try: + await ctx.registry.update_lane_status(lane.id, "busy") await ctx.client.turn_start( lane.id, settings.text, - cwd=str(resolved.cwd), + cwd=str(effective_cwd), approval_policy=approval_policy, approvals_reviewer=settings.approvals_reviewer, - sandbox_policy=_turn_sandbox(sandbox), + sandbox_policy=thread_sandbox_to_turn_policy(sandbox), effort=settings.effort, summary=settings.summary, model=settings.model, @@ -599,21 +654,12 @@ async def new_lane(inp: NewInput, ctx: Ctx) -> NewLane: message_accepted=message_accepted, goal_set=goal_set, staged=staged, + workspace=workspace.view, latest_turn=_latest_turn_view(lane), model=_model_view(lane_model), ) -def _turn_sandbox(sandbox: ThreadSandbox) -> SandboxPolicy: - match sandbox: - case "read-only": - return SandboxPolicy(type="readOnly") - case "workspace-write": - return SandboxPolicy(type="workspaceWrite") - case "danger-full-access": - return SandboxPolicy(type="dangerFullAccess") - - async def attach_lane(inp: AttachInput, ctx: Ctx) -> LaneRef: existing = await ctx.registry.find_lane(inp.thread) if existing is not None: @@ -785,8 +831,21 @@ async def send(inp: LaneTextInput, ctx: Ctx) -> ActionAck: _require_writable(lane, ctx) try: await _prepare_attached_write(lane, ctx) + turn_settings = await load_turn_start_settings(ctx.registry, lane.id) + await ctx.registry.update_lane_status(lane.id, "busy") await ctx.client.turn_start( - lane.id, inp.text, cwd=lane.cwd or ".", sandbox_policy=_READ_ONLY + lane.id, + inp.text, + cwd=lane.cwd or ".", + approval_policy=turn_settings.approval_policy, + approvals_reviewer=turn_settings.approvals_reviewer, + sandbox_policy=turn_settings.sandbox_policy, + effort=turn_settings.effort, + summary=turn_settings.summary, + model=turn_settings.model, + service_tier=turn_settings.service_tier, + output_schema=turn_settings.output_schema, + personality=turn_settings.personality, ) except (DispatchError, ClientError) as exc: await ctx.registry.record_turn_request_failed(lane.id, str(exc)) @@ -794,7 +853,6 @@ async def send(inp: LaneTextInput, ctx: Ctx) -> ActionAck: "send", lane=lane.id, detail=inp.text[:120], outcome=project_error(exc).code ) raise - await ctx.registry.update_lane_status(lane.id, "busy") await ctx.registry.log_action("send", lane=lane.id, detail=inp.text[:120]) return ActionAck(**_managed_identity(lane, ctx), op="send") @@ -816,8 +874,21 @@ async def send_message(inp: SendInput, ctx: Ctx) -> ActionAck: await _prepare_attached_write(lane, ctx) await ctx.client.turn_interrupt(lane.id, turn_id) await ctx.registry.log_action("interrupt", lane=lane.id, detail="interject") + turn_settings = await load_turn_start_settings(ctx.registry, lane.id) + await ctx.registry.update_lane_status(lane.id, "busy") await ctx.client.turn_start( - lane.id, text, cwd=lane.cwd or ".", sandbox_policy=_READ_ONLY + lane.id, + text, + cwd=lane.cwd or ".", + approval_policy=turn_settings.approval_policy, + approvals_reviewer=turn_settings.approvals_reviewer, + sandbox_policy=turn_settings.sandbox_policy, + effort=turn_settings.effort, + summary=turn_settings.summary, + model=turn_settings.model, + service_tier=turn_settings.service_tier, + output_schema=turn_settings.output_schema, + personality=turn_settings.personality, ) except (DispatchError, ClientError) as exc: await ctx.registry.record_turn_request_failed(lane.id, str(exc)) @@ -825,7 +896,6 @@ async def send_message(inp: SendInput, ctx: Ctx) -> ActionAck: "send", lane=lane.id, detail=text[:120], outcome=project_error(exc).code ) raise - await ctx.registry.update_lane_status(lane.id, "busy") await ctx.registry.log_action("send", lane=lane.id, detail=text[:120]) return ActionAck(**_managed_identity(lane, ctx), op="interject") case "queue": @@ -986,6 +1056,60 @@ async def transcript(inp: TranscriptInput, ctx: Ctx) -> TranscriptOutput: ) +async def history(inp: HistoryInput, ctx: Ctx) -> HistoryOutput: + mode = _history_mode(inp) + if mode == "overview": + if inp.lane is not None: + raise ValidationError("history overview does not accept a thread selector") + lanes = (await ctx.registry.list_lanes())[: inp.limit] + summaries = [await _history_summary_for_lane(lane, ctx) for lane in lanes] + return HistoryOutput(mode="overview", threads=summaries) + + if inp.lane is None: + raise ValidationError("history view requires a thread selector") + lane = await _resolve(ctx, inp.lane) + result = await ctx.client.thread_read(lane.id, include_turns=True) + summary, _items, tools, files = await _history_details(lane, result, ctx) + if mode == "summary": + return HistoryOutput(mode="summary", thread=summary, tools=tools, files=files) + if mode == "tools": + return HistoryOutput(mode="tools", thread=summary, tools=tools[: inp.limit]) + if mode == "files": + return HistoryOutput(mode="files", thread=summary, files=files[: inp.limit]) + return HistoryOutput( + mode="items", + thread=summary, + items=history_items_from_thread( + result, + item_type=inp.item_type, + tool=inp.tool, + grep=inp.grep, + raw=inp.raw, + limit=inp.limit, + ), + ) + + +def _history_mode(inp: HistoryInput) -> Literal["overview", "summary", "items", "tools", "files"]: + if inp.view == "auto": + return "overview" if inp.lane is None else "summary" + return inp.view + + +async def _history_summary_for_lane(lane: Lane, ctx: Ctx) -> HistoryThreadSummary: + result = await ctx.client.thread_read(lane.id, include_turns=True) + summary, _items, _tools, _files = await _history_details(lane, result, ctx) + return summary + + +async def _history_details( + lane: Lane, result: dict[str, object], ctx: Ctx +) -> tuple[HistoryThreadSummary, list[HistoryItem], list[HistoryToolStat], list[HistoryFileStat]]: + sync = await ctx.registry.get_lane_sync(lane.id) + worktree = await detect_worktree(lane.cwd) + return summarize_history(result, lane=lane, sync=sync, worktree=worktree) + + async def search(inp: SearchInput, ctx: Ctx) -> SearchOutput: if inp.managed and inp.unmanaged: raise ValidationError("search can filter --managed or --unmanaged, not both") @@ -1370,6 +1494,17 @@ async def fork(inp: ForkInput, ctx: Ctx) -> LaneRef: await ctx.registry.upsert_lane_model_settings( resolved_model.for_lane(lane.id, ctx.registry.now_iso()) ) + await ctx.registry.upsert_lane_runtime_settings( + runtime_settings_for_lane( + lane=lane.id, + updated_at=ctx.registry.now_iso(), + sandbox=inp.sandbox or "read-only", + approval_policy=inp.approval_policy or "never", + approvals_reviewer=inp.approvals_reviewer, + model=inp.model, + service_tier=explicit_service_tier, + ) + ) await ctx.registry.log_action("fork", lane=lane.id, detail=f"from {source.id}") try: await ctx.client.thread_set_name(thread.id, handle.removeprefix("@")) diff --git a/src/outfitter/dispatch/core/history.py b/src/outfitter/dispatch/core/history.py new file mode 100644 index 0000000..24e4eb1 --- /dev/null +++ b/src/outfitter/dispatch/core/history.py @@ -0,0 +1,281 @@ +"""Transcript history summarization and filtering helpers.""" + +from __future__ import annotations + +import json +import re +import subprocess +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any + +from outfitter.dispatch.registry.models import Lane, LaneSync + +from .models import ( + HistoryFileStat, + HistoryItem, + HistoryThreadSummary, + HistoryToolStat, + HistoryWorktree, +) + + +def history_items_from_thread( + result: dict[str, object], + *, + item_type: str | None = None, + tool: str | None = None, + grep: str | None = None, + raw: bool = False, + limit: int = 50, +) -> list[HistoryItem]: + items = _all_history_items(result, raw=raw) + filtered = [ + item for item in items if _matches_filter(item, item_type=item_type, tool=tool, grep=grep) + ] + return filtered[-limit:] + + +def summarize_history( + result: dict[str, object], + *, + lane: Lane, + sync: LaneSync | None, + worktree: HistoryWorktree | None = None, +) -> tuple[HistoryThreadSummary, list[HistoryItem], list[HistoryToolStat], list[HistoryFileStat]]: + items = _all_history_items(result, raw=False) + thread = result.get("thread") + turns = _turns(thread if isinstance(thread, dict) else {}) + transcript_bytes = ( + len(json.dumps(thread, separators=(",", ":"))) if isinstance(thread, dict) else None + ) + tool_counter: Counter[str] = Counter(item.tool for item in items if item.tool) + item_types_by_tool: dict[str, set[str]] = defaultdict(set) + for item in items: + if item.tool: + item_types_by_tool[item.tool].add(item.type) + file_counter: Counter[str] = Counter() + for item in items: + file_counter.update(item.files) + tools = [ + HistoryToolStat( + tool=name, + count=count, + item_types=sorted(item_types_by_tool[name]), + ) + for name, count in tool_counter.most_common() + ] + files = [HistoryFileStat(path=path, count=count) for path, count in file_counter.most_common()] + subagent_ids = sorted( + {value for item in items for value in _subagent_thread_ids(item.raw or {})} + ) + summary = HistoryThreadSummary( + ref=lane.ref, + id=lane.id, + handle=lane.handle, + source=lane.source, + status=lane.status, + cwd=lane.cwd, + first_event_at=_first_event_at(thread if isinstance(thread, dict) else {}), + last_event_at=sync.latest_event_at if sync is not None else None, + turns=len(turns), + items=len(items), + messages=sum(1 for item in items if _is_message(item)), + tool_calls=sum(tool_counter.values()), + unique_tools=sorted(tool_counter), + files_changed_count=len(files), + files_changed=files[:25], + transcript_bytes=transcript_bytes, + estimated_tokens=(transcript_bytes // 4) if transcript_bytes is not None else None, + subagents_count=len(subagent_ids), + subagent_thread_ids=subagent_ids, + worktree=worktree or HistoryWorktree(), + ) + return summary, items, tools, files + + +async def detect_worktree(cwd: str | None) -> HistoryWorktree: + if cwd is None: + return HistoryWorktree() + return await _detect_worktree(cwd) + + +async def _detect_worktree(cwd: str) -> HistoryWorktree: + import asyncio + + return await asyncio.to_thread(_detect_worktree_sync, cwd) + + +def _detect_worktree_sync(cwd: str) -> HistoryWorktree: + path = Path(cwd).expanduser() + if not path.exists(): + return HistoryWorktree(path=str(path), is_codex_worktree=_looks_like_codex_worktree(path)) + repo = _git(cwd, "rev-parse", "--show-toplevel") + branch = _git(cwd, "branch", "--show-current") + head = _git(cwd, "rev-parse", "--short", "HEAD") + common_dir = _git(cwd, "rev-parse", "--git-common-dir") + detected = bool(repo and common_dir and Path(common_dir).name == "worktrees") + return HistoryWorktree( + detected=detected, + path=str(path), + repo=repo, + branch=branch, + head=head, + is_codex_worktree=_looks_like_codex_worktree(path), + ) + + +def _git(cwd: str, *args: str) -> str | None: + try: + proc = subprocess.run( + ("git", "-C", cwd, *args), + check=False, + capture_output=True, + text=True, + timeout=1, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if proc.returncode != 0: + return None + value = proc.stdout.strip() + return value or None + + +def _looks_like_codex_worktree(path: Path) -> bool: + raw = str(path) + return "/.config/codex/worktrees/" in raw or "/.codex/worktrees/" in raw + + +def _all_history_items(result: dict[str, object], *, raw: bool) -> list[HistoryItem]: + thread = result.get("thread") + if not isinstance(thread, dict): + return [] + items: list[HistoryItem] = [] + for turn in _turns(thread): + turn_id = _string(turn.get("id")) + raw_items = turn.get("items") + if not isinstance(raw_items, list): + continue + for raw_item in raw_items: + if not isinstance(raw_item, dict): + continue + items.append(_history_item(turn_id, raw_item, include_raw=raw)) + return items + + +def _history_item( + turn_id: str | None, item: dict[str, object], *, include_raw: bool +) -> HistoryItem: + item_type = _string(item.get("type")) or "unknown" + return HistoryItem( + turn_id=turn_id, + item_id=_string(item.get("id")), + type=item_type, + text=_item_text(item), + role=_string(item.get("role")), + tool=_tool_name(item), + files=_file_paths(item), + raw=dict(item) if include_raw else None, + ) + + +def _turns(thread: dict[str, object]) -> list[dict[str, object]]: + raw_turns = thread.get("turns") + if not isinstance(raw_turns, list): + return [] + return [turn for turn in raw_turns if isinstance(turn, dict)] + + +def _matches_filter( + item: HistoryItem, + *, + item_type: str | None, + tool: str | None, + grep: str | None, +) -> bool: + if item_type is not None and item_type.casefold() not in item.type.casefold(): + return False + if tool is not None and (item.tool is None or tool.casefold() not in item.tool.casefold()): + return False + return not (grep is not None and grep.casefold() not in (item.text or "").casefold()) + + +def _is_message(item: HistoryItem) -> bool: + return "message" in item.type.casefold() or item.role in {"user", "assistant", "system"} + + +def _tool_name(item: dict[str, object]) -> str | None: + for key in ("toolName", "tool_name", "name", "command"): + value = _string(item.get(key)) + if value: + return value + item_type = _string(item.get("type")) or "" + if "tool" in item_type.casefold(): + return item_type + return None + + +def _file_paths(item: dict[str, object]) -> list[str]: + found: list[str] = [] + _collect_paths(item, found) + return sorted(set(found)) + + +def _collect_paths(value: object, found: list[str]) -> None: + if isinstance(value, dict): + for key, child in value.items(): + if key in {"path", "file", "filePath", "file_path"} and isinstance(child, str): + found.append(child) + else: + _collect_paths(child, found) + elif isinstance(value, list): + for child in value: + _collect_paths(child, found) + + +def _subagent_thread_ids(item: dict[str, object]) -> list[str]: + blob = json.dumps(item, separators=(",", ":")) + return re.findall(r"019[a-z0-9-]{28,}", blob) + + +def _first_event_at(thread: dict[str, object]) -> str | None: + timestamps: list[str] = [] + for turn in _turns(thread): + for key in ("createdAt", "created_at", "timestamp"): + value = _string(turn.get(key)) + if value: + timestamps.append(value) + raw_items = turn.get("items") + if isinstance(raw_items, list): + for item in raw_items: + if isinstance(item, dict): + value = _string(item.get("createdAt")) or _string(item.get("timestamp")) + if value: + timestamps.append(value) + return min(timestamps) if timestamps else None + + +def _item_text(item: dict[str, object]) -> str | None: + direct = item.get("text") + if isinstance(direct, str): + return direct + content = item.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for part in content: + if isinstance(part, str): + parts.append(part) + elif isinstance(part, dict): + text = part.get("text") + if isinstance(text, str): + parts.append(text) + if parts: + return "\n".join(parts) + return None + + +def _string(value: Any) -> str | None: + return value if isinstance(value, str) else None diff --git a/src/outfitter/dispatch/core/models.py b/src/outfitter/dispatch/core/models.py index 2e57d3d..4774143 100644 --- a/src/outfitter/dispatch/core/models.py +++ b/src/outfitter/dispatch/core/models.py @@ -30,6 +30,9 @@ ThreadActionSource = Literal["own", "attached", "unmanaged"] SearchSortKey = Literal["created_at", "updated_at"] SearchDateField = Literal["created_at", "updated_at"] +HistoryView = Literal["auto", "overview", "summary", "items", "tools", "files"] +WorkspaceSetupMode = Literal["auto", "skip", "run"] +WorktreeMode = Literal["none", "create"] THREAD_SELECTOR_DESCRIPTION = ( "Thread selector: dispatch ref, full Codex thread id, or unique handle/title." ) @@ -104,6 +107,27 @@ class NewInput(BaseModel): default=None, description="Packet parts to keep inline only (not staged): 'all' or a comma list.", ) + workspace: str | None = Field( + default=None, + description="Workspace preflight mode: none, auto, or a named workspace preset.", + ) + workspace_setup: WorkspaceSetupMode = Field( + default="auto", + description="Workspace setup execution: auto, skip, or run.", + ) + worktree: WorktreeMode | None = Field( + default=None, + description=("Git worktree preflight: none or create; omit to use workspace config."), + ) + worktree_path: str | None = Field( + default=None, description="Explicit path for a Dispatch-created git worktree." + ) + worktree_branch: str | None = Field( + default=None, description="Branch to check out/create in a Dispatch-created worktree." + ) + worktree_base: str | None = Field( + default=None, description="Base ref for new Dispatch-created worktree branches." + ) class AttachInput(BaseModel): @@ -208,6 +232,22 @@ class TranscriptInput(BaseModel): limit: int = Field(default=50, ge=1, description="Max compact transcript items to return.") +class HistoryInput(BaseModel): + lane: str | None = Field( + default=None, + description="Optional thread selector. Omit for managed-thread overview.", + ) + view: HistoryView = Field( + default="auto", + description="History view: auto, overview, summary, items, tools, or files.", + ) + item_type: str | None = Field(default=None, description="Only include matching item types.") + tool: str | None = Field(default=None, description="Only include matching tool names.") + grep: str | None = Field(default=None, description="Only include items containing text.") + raw: bool = Field(default=False, description="Include raw App Server item payloads.") + limit: int = Field(default=50, ge=1, description="Max rows/items to return.") + + class GoalGetInput(BaseModel): lane: str = Field(description=THREAD_SELECTOR_DESCRIPTION) @@ -413,6 +453,7 @@ class NewLane(LaneRef): staged: StageView = Field( default_factory=StageView, description="Packet parts staged to the session directory." ) + workspace: WorkspaceView = Field(description="Workspace preflight result.") latest_turn: LatestTurnView = Field(default_factory=LatestTurnView) model: ThreadModelView = Field(default_factory=ThreadModelView) @@ -446,12 +487,60 @@ class LaunchSettingsView(BaseModel): ephemeral: bool = False +class WorkspaceEnvironmentView(BaseModel): + version: int | None = None + name: str | None = None + setup_script: str | None = None + cleanup_script: str | None = None + unknown_keys: list[str] = Field(default_factory=list) + + +class WorkspaceSetupView(BaseModel): + policy: str = Field(description="Setup policy decision.") + ran: bool = Field(description="Whether the setup script ran.") + script: str | None = None + cwd: str | None = None + exit_code: int | None = None + duration_ms: int | None = None + stdout_tail: str | None = None + stderr_tail: str | None = None + + +class WorktreeView(BaseModel): + mode: WorktreeMode = "none" + state: str = Field(description="Worktree preflight state.") + path: str | None = None + branch: str | None = None + base: str | None = None + head: str | None = None + source_repo: str | None = None + created: bool = False + + +class WorkspaceView(BaseModel): + mode: str = Field(description="Requested workspace mode or preset.") + resolved_mode: str = Field(description="Resolved workspace mode after preset expansion.") + state: str = Field(description="Workspace preflight state.") + input_cwd: str = Field(description="Input cwd before workspace resolution.") + repo_root: str | None = Field(default=None, description="Detected git repo root, if any.") + effective_cwd: str = Field(description="Exact cwd Dispatch will pass to App Server.") + environment_file: str | None = Field( + default=None, description="Resolved .codex environment file, if discovered." + ) + environment: WorkspaceEnvironmentView | None = None + setup: WorkspaceSetupView = Field( + default_factory=lambda: WorkspaceSetupView(policy="none", ran=False) + ) + worktree: WorktreeView = Field(default_factory=lambda: WorktreeView(state="disabled")) + + class LaunchPlan(BaseModel): """A mutation-free preview of what ``dispatch new`` would launch (``--dry-run``).""" name: str = Field(description="Resolved display name (after prefix/presets).") handle: str = Field(description="Resolved @handle the lane would receive.") cwd: str = Field(description="Resolved working directory.") + workspace: WorkspaceView = Field(description="Workspace preflight plan.") packet: str | None = Field(default=None, description="Resolved packet directory, if any.") settings: LaunchSettingsView = Field(description="Effective lane settings.") sources: list[LaunchInputSource] = Field( @@ -489,6 +578,65 @@ class TranscriptItem(BaseModel): text: str | None = None +class HistoryWorktree(BaseModel): + detected: bool = False + path: str | None = None + repo: str | None = None + branch: str | None = None + head: str | None = None + is_codex_worktree: bool = False + + +class HistoryToolStat(BaseModel): + tool: str + count: int + item_types: list[str] = Field(default_factory=list) + + +class HistoryFileStat(BaseModel): + path: str + count: int + + +class HistoryItem(TranscriptItem): + role: str | None = None + tool: str | None = None + files: list[str] = Field(default_factory=list) + raw: dict[str, object] | None = None + + +class HistoryThreadSummary(BaseModel): + ref: str | None = None + id: str + handle: str | None = None + source: LaneSource | None = None + status: LaneStatus | None = None + cwd: str | None = None + first_event_at: str | None = None + last_event_at: str | None = None + turns: int = 0 + items: int = 0 + messages: int = 0 + tool_calls: int = 0 + unique_tools: list[str] = Field(default_factory=list) + files_changed_count: int = 0 + files_changed: list[HistoryFileStat] = Field(default_factory=list) + transcript_bytes: int | None = None + estimated_tokens: int | None = None + subagents_count: int = 0 + subagent_thread_ids: list[str] = Field(default_factory=list) + worktree: HistoryWorktree = Field(default_factory=HistoryWorktree) + + +class HistoryOutput(BaseModel): + mode: Literal["overview", "summary", "items", "tools", "files"] + threads: list[HistoryThreadSummary] = Field(default_factory=list) + thread: HistoryThreadSummary | None = None + items: list[HistoryItem] = Field(default_factory=list) + tools: list[HistoryToolStat] = Field(default_factory=list) + files: list[HistoryFileStat] = Field(default_factory=list) + + class WatchEvent(BaseModel): method: str params: dict[str, object] = Field(default_factory=dict) diff --git a/src/outfitter/dispatch/core/new_config.py b/src/outfitter/dispatch/core/new_config.py index e3cd3da..b76af26 100644 --- a/src/outfitter/dispatch/core/new_config.py +++ b/src/outfitter/dispatch/core/new_config.py @@ -59,11 +59,33 @@ def merged(self, other: NewSettings) -> NewSettings: return self.model_copy(update=update) +class WorkspacePreset(BaseModel): + model_config = ConfigDict(extra="forbid") + + mode: str = "auto" + worktree: str | None = None + worktree_path: str | None = None + worktree_branch: str | None = None + worktree_base: str | None = None + + +class WorkspaceConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + default: str | None = None + worktree: str | None = None + worktree_path: str | None = None + worktree_branch: str | None = None + worktree_base: str | None = None + presets: dict[str, WorkspacePreset] = Field(default_factory=dict) + + class NewConfigFile(BaseModel): model_config = ConfigDict(extra="forbid") defaults: NewSettings = Field(default_factory=NewSettings) presets: dict[str, NewSettings] = Field(default_factory=dict) + workspace: WorkspaceConfig = Field(default_factory=WorkspaceConfig) policy: dict[str, object] = Field(default_factory=dict, exclude=True) @@ -77,6 +99,7 @@ def __init__( handle: str, base_instructions: str | None, developer_instructions: str | None, + workspace: WorkspaceConfig, ) -> None: self.settings = settings self.cwd = cwd @@ -84,6 +107,7 @@ def __init__( self.handle = handle self.base_instructions = base_instructions self.developer_instructions = developer_instructions + self.workspace = workspace def resolve_new( @@ -103,6 +127,10 @@ def resolve_new( config_path = _find_config(start_cwd) config_dir = config_path.parent.parent if config_path is not None else None config = _load_config(config_path) if config_path is not None else NewConfigFile() + if config_dir is not None: + config = config.model_copy( + update={"workspace": _resolve_workspace_paths(config.workspace, base=config_dir)} + ) settings = NewSettings( cwd=str(start_cwd), @@ -141,6 +169,7 @@ def resolve_new( handle=_handle(display_name), base_instructions=base_instructions, developer_instructions=developer_instructions, + workspace=config.workspace, ) @@ -185,6 +214,23 @@ def _flatten_instructions(section: dict[str, Any]) -> dict[str, Any]: return flattened +def _resolve_workspace_paths(config: WorkspaceConfig, *, base: Path) -> WorkspaceConfig: + presets = { + name: preset.model_copy( + update={ + "worktree_path": _resolve_optional_path(preset.worktree_path, base=base), + } + ) + for name, preset in config.presets.items() + } + return config.model_copy( + update={ + "worktree_path": _resolve_optional_path(config.worktree_path, base=base), + "presets": presets, + } + ) + + def _find_config(cwd: Path) -> Path | None: for path in (cwd, *cwd.parents): candidate = path / _CONFIG_PATH @@ -203,6 +249,12 @@ def _resolve_path(value: str, *, base: Path) -> Path: return path if path.is_absolute() else (base / path).resolve() +def _resolve_optional_path(value: str | None, *, base: Path) -> str | None: + if value is None: + return None + return str(_resolve_path(value, base=base)) + + def _resolve_instructions(*, inline: str | None, file: str | None, base: Path) -> str | None: if inline is not None: return inline diff --git a/src/outfitter/dispatch/core/ops.py b/src/outfitter/dispatch/core/ops.py index c83a208..ae84fda 100644 --- a/src/outfitter/dispatch/core/ops.py +++ b/src/outfitter/dispatch/core/ops.py @@ -21,6 +21,8 @@ GoalGetInput, GoalSetInput, GoalView, + HistoryInput, + HistoryOutput, LaneDetail, LaneInput, LaneRef, @@ -140,6 +142,36 @@ "message_accepted": False, "goal_set": False, "staged": {"parts": [], "session_dir": None, "files": []}, + "workspace": { + "mode": "none", + "resolved_mode": "none", + "state": "disabled", + "input_cwd": "/work", + "repo_root": None, + "effective_cwd": "/work", + "environment_file": None, + "environment": None, + "setup": { + "policy": "not_requested", + "ran": False, + "script": None, + "cwd": None, + "exit_code": None, + "duration_ms": None, + "stdout_tail": None, + "stderr_tail": None, + }, + "worktree": { + "mode": "none", + "state": "disabled", + "path": None, + "branch": None, + "base": None, + "head": None, + "source_repo": None, + "created": False, + }, + }, "latest_turn": { "id": None, "status": None, @@ -178,6 +210,36 @@ "name": "[demo] preview", "handle": "@[demo] preview", "cwd": "/work", + "workspace": { + "mode": "none", + "resolved_mode": "none", + "state": "disabled", + "input_cwd": "/work", + "repo_root": None, + "effective_cwd": "/work", + "environment_file": None, + "environment": None, + "setup": { + "policy": "not_requested", + "ran": False, + "script": None, + "cwd": None, + "exit_code": None, + "duration_ms": None, + "stdout_tail": None, + "stderr_tail": None, + }, + "worktree": { + "mode": "none", + "state": "disabled", + "path": None, + "branch": None, + "base": None, + "head": None, + "source_repo": None, + "created": False, + }, + }, "packet": None, "settings": { "sandbox": "read-only", @@ -319,6 +381,30 @@ examples=[Example("missing", input={"lane": "nope"}, raises=NotFoundError)], ) +HISTORY = define_op( + id="history", + summary="Inspect transcript history, tools, files, and per-thread summary facts.", + input=HistoryInput, + output=HistoryOutput, + intent="read", + idempotent=True, + handler=handlers.history, + examples=[ + Example( + "overview", + input={}, + output={ + "mode": "overview", + "threads": [], + "thread": None, + "items": [], + "tools": [], + "files": [], + }, + ) + ], +) + WATCH = define_op( id="watch", summary="Collect a bounded live App Server event sample for a managed thread.", @@ -635,6 +721,7 @@ SHOW, LANE_RENAME, TRANSCRIPT, + HISTORY, WATCH, SYNC, ROSTER, diff --git a/src/outfitter/dispatch/core/queue.py b/src/outfitter/dispatch/core/queue.py index 723780b..068faea 100644 --- a/src/outfitter/dispatch/core/queue.py +++ b/src/outfitter/dispatch/core/queue.py @@ -8,11 +8,10 @@ from __future__ import annotations from outfitter.dispatch.client.errors import ClientError -from outfitter.dispatch.client.models import SandboxPolicy from outfitter.dispatch.contracts.context import Ctx from outfitter.dispatch.contracts.errors import DispatchError, project_error -_READ_ONLY = SandboxPolicy(type="readOnly") +from .turn_settings import load_turn_start_settings async def drain_next_queued_message(ctx: Ctx, lane_id: str) -> bool: @@ -32,12 +31,26 @@ async def drain_next_queued_message(ctx: Ctx, lane_id: str) -> bool: try: if lane.source == "attached" and ctx.policy.allow_attached_writes: await ctx.client.thread_resume(lane.id, exclude_turns=True) + turn_settings = await load_turn_start_settings(ctx.registry, lane.id) + await ctx.registry.update_lane_status(lane.id, "busy") await ctx.client.turn_start( - lane.id, message.text, cwd=lane.cwd or ".", sandbox_policy=_READ_ONLY + lane.id, + message.text, + cwd=lane.cwd or ".", + approval_policy=turn_settings.approval_policy, + approvals_reviewer=turn_settings.approvals_reviewer, + sandbox_policy=turn_settings.sandbox_policy, + effort=turn_settings.effort, + summary=turn_settings.summary, + model=turn_settings.model, + service_tier=turn_settings.service_tier, + output_schema=turn_settings.output_schema, + personality=turn_settings.personality, ) except (DispatchError, ClientError) as exc: projected = project_error(exc) await ctx.registry.fail_queued_message(message.id, projected.code) + await ctx.registry.record_turn_request_failed(lane.id, str(exc)) await ctx.registry.log_action( "queue", lane=lane.id, @@ -46,7 +59,6 @@ async def drain_next_queued_message(ctx: Ctx, lane_id: str) -> bool: ) return True await ctx.registry.complete_queued_message(message.id) - await ctx.registry.update_lane_status(lane.id, "busy") await ctx.registry.log_action("send", lane=lane.id, detail=message.text[:120], outcome="queued") return True diff --git a/src/outfitter/dispatch/core/turn_settings.py b/src/outfitter/dispatch/core/turn_settings.py new file mode 100644 index 0000000..86636dd --- /dev/null +++ b/src/outfitter/dispatch/core/turn_settings.py @@ -0,0 +1,86 @@ +"""Helpers for reusing a lane's saved turn-start runtime settings.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from outfitter.dispatch.client.models import ( + ApprovalPolicy, + ApprovalsReviewer, + Effort, + Personality, + ReasoningSummary, + SandboxPolicy, + ThreadSandbox, +) +from outfitter.dispatch.registry.models import LaneRuntimeSettings +from outfitter.dispatch.registry.store import Registry + + +@dataclass(frozen=True) +class TurnStartSettings: + sandbox_policy: SandboxPolicy = field(default_factory=lambda: SandboxPolicy(type="readOnly")) + approval_policy: ApprovalPolicy = "never" + approvals_reviewer: ApprovalsReviewer | None = None + effort: Effort | None = None + summary: ReasoningSummary | None = None + model: str | None = None + service_tier: str | None = None + output_schema: dict[str, object] | None = None + personality: Personality | None = None + + +def thread_sandbox_to_turn_policy(sandbox: ThreadSandbox) -> SandboxPolicy: + match sandbox: + case "read-only": + return SandboxPolicy(type="readOnly") + case "workspace-write": + return SandboxPolicy(type="workspaceWrite") + case "danger-full-access": + return SandboxPolicy(type="dangerFullAccess") + + +def runtime_settings_for_lane( + *, + lane: str, + updated_at: str, + sandbox: ThreadSandbox = "read-only", + approval_policy: ApprovalPolicy = "never", + approvals_reviewer: ApprovalsReviewer | None = None, + effort: Effort | None = None, + summary: ReasoningSummary | None = None, + model: str | None = None, + service_tier: str | None = None, + output_schema: dict[str, object] | None = None, + personality: Personality | None = None, +) -> LaneRuntimeSettings: + return LaneRuntimeSettings( + lane=lane, + sandbox=sandbox, + approval_policy=approval_policy, + approvals_reviewer=approvals_reviewer, + effort=effort, + summary=summary, + model=model, + service_tier=service_tier, + output_schema=output_schema, + personality=personality, + updated_at=updated_at, + ) + + +async def load_turn_start_settings(registry: Registry, lane_id: str) -> TurnStartSettings: + stored = await registry.get_lane_runtime_settings(lane_id) + if stored is None: + return TurnStartSettings() + return TurnStartSettings( + sandbox_policy=thread_sandbox_to_turn_policy(stored.sandbox), + approval_policy=stored.approval_policy, + approvals_reviewer=stored.approvals_reviewer, + effort=stored.effort, + summary=stored.summary, + model=stored.model, + service_tier=stored.service_tier, + output_schema=stored.output_schema, + personality=stored.personality, + ) diff --git a/src/outfitter/dispatch/core/workspace.py b/src/outfitter/dispatch/core/workspace.py new file mode 100644 index 0000000..1e7f756 --- /dev/null +++ b/src/outfitter/dispatch/core/workspace.py @@ -0,0 +1,554 @@ +"""Workspace preflight for ``dispatch new``. + +Workspace launch is deliberately smaller than a hook framework. Dispatch can +discover repo-local Codex environment metadata, optionally run a trusted setup +script, and report the exact cwd it will pass to App Server. It does not infer +domain workflows or execute packet-local hooks. +""" + +from __future__ import annotations + +import asyncio +import re +import subprocess +import time +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, cast + +from outfitter.dispatch.config import RuntimePolicy, worktree_root_path +from outfitter.dispatch.contracts.errors import ValidationError + +from .models import ( + WorkspaceEnvironmentView, + WorkspaceSetupMode, + WorkspaceSetupView, + WorkspaceView, + WorktreeMode, + WorktreeView, +) +from .new_config import WorkspaceConfig + +_ENV_PATH = Path(".codex") / "environments" / "environment.toml" +_TOP_LEVEL_KEYS = {"version", "name", "setup", "cleanup"} +_OUTPUT_TAIL_CHARS = 4000 +_SLUG_RE = re.compile(r"[^A-Za-z0-9._-]+") + +WorkspaceState = Literal["disabled", "not_found", "discovered", "setup_completed"] + + +@dataclass(frozen=True) +class WorkspaceResolution: + effective_cwd: Path + view: WorkspaceView + setup_script: str | None + + +@dataclass(frozen=True) +class WorkspaceOptions: + mode: str + resolved_mode: str + worktree: WorktreeMode + worktree_path: str | None + worktree_branch: str | None + worktree_base: str | None + + +def plan_workspace( + *, + cwd: Path, + name: str, + requested: str | None, + setup: WorkspaceSetupMode, + worktree: WorktreeMode | None, + worktree_path: str | None, + worktree_branch: str | None, + worktree_base: str | None, + config: WorkspaceConfig, + policy: RuntimePolicy, +) -> WorkspaceResolution: + """Resolve workspace metadata without running setup.""" + options = _resolve_options( + requested=requested, + config=config, + worktree=worktree, + worktree_path=worktree_path, + worktree_branch=worktree_branch, + worktree_base=worktree_base, + ) + input_cwd = _absolute(cwd) + source_repo = _find_repo_root(input_cwd) + worktree_view, effective_cwd = _plan_worktree( + mode=options.worktree, + input_cwd=input_cwd, + source_repo=source_repo, + name=name, + path=options.worktree_path, + branch=options.worktree_branch, + base=options.worktree_base, + ) + if options.resolved_mode == "none": + return WorkspaceResolution( + effective_cwd=effective_cwd, + view=_view( + mode=options.mode, + resolved_mode=options.resolved_mode, + state="disabled", + input_cwd=input_cwd, + repo_root=source_repo, + effective_cwd=effective_cwd, + setup_view=WorkspaceSetupView(policy="not_requested", ran=False), + worktree_view=worktree_view, + ), + setup_script=None, + ) + + repo_root = _find_repo_root(effective_cwd) or source_repo + env_file = _find_environment_file(effective_cwd, repo_root) + if env_file is None: + return WorkspaceResolution( + effective_cwd=effective_cwd, + view=_view( + mode=options.mode, + resolved_mode=options.resolved_mode, + state="not_found", + input_cwd=input_cwd, + repo_root=repo_root, + effective_cwd=effective_cwd, + setup_view=WorkspaceSetupView(policy="not_found", ran=False), + worktree_view=worktree_view, + ), + setup_script=None, + ) + + environment = _load_environment(env_file) + setup_policy = _setup_policy(setup, policy, has_script=environment.setup_script is not None) + env_effective_cwd = ( + effective_cwd if options.worktree == "create" else repo_root or effective_cwd + ) + return WorkspaceResolution( + effective_cwd=env_effective_cwd, + view=_view( + mode=options.mode, + resolved_mode=options.resolved_mode, + state="discovered", + input_cwd=input_cwd, + repo_root=repo_root, + effective_cwd=env_effective_cwd, + environment_file=env_file, + environment=environment, + setup_view=WorkspaceSetupView(policy=setup_policy, ran=False), + worktree_view=worktree_view, + ), + setup_script=environment.setup_script, + ) + + +async def prepare_workspace( + *, + cwd: Path, + name: str, + requested: str | None, + setup: WorkspaceSetupMode, + worktree: WorktreeMode | None, + worktree_path: str | None, + worktree_branch: str | None, + worktree_base: str | None, + config: WorkspaceConfig, + policy: RuntimePolicy, +) -> WorkspaceResolution: + """Resolve workspace metadata and run setup when trusted/explicitly requested.""" + source_cwd = _absolute(cwd) + source_repo = _find_repo_root(source_cwd) + options = _resolve_options( + requested=requested, + config=config, + worktree=worktree, + worktree_path=worktree_path, + worktree_branch=worktree_branch, + worktree_base=worktree_base, + ) + worktree_view, effective_cwd = await _prepare_worktree( + mode=options.worktree, + input_cwd=source_cwd, + source_repo=source_repo, + name=name, + path=options.worktree_path, + branch=options.worktree_branch, + base=options.worktree_base, + ) + planned = plan_workspace( + cwd=effective_cwd, + name=name, + requested=options.mode, + setup=setup, + worktree="none", + worktree_path=None, + worktree_branch=None, + worktree_base=None, + config=config, + policy=policy, + ) + planned = WorkspaceResolution( + effective_cwd=planned.effective_cwd, + view=planned.view.model_copy( + update={"input_cwd": str(source_cwd), "worktree": worktree_view} + ), + setup_script=planned.setup_script, + ) + if planned.setup_script is None or planned.view.setup.policy not in {"explicit", "trusted"}: + return planned + + setup_result = await _run_setup( + script=planned.setup_script, + cwd=Path(planned.view.repo_root or planned.effective_cwd), + policy_name=planned.view.setup.policy, + timeout_seconds=policy.workspace_setup_timeout_seconds, + ) + view = planned.view.model_copy(update={"state": "setup_completed", "setup": setup_result}) + return WorkspaceResolution( + effective_cwd=planned.effective_cwd, + view=view, + setup_script=planned.setup_script, + ) + + +def _resolve_options( + *, + requested: str | None, + config: WorkspaceConfig, + worktree: WorktreeMode | None, + worktree_path: str | None, + worktree_branch: str | None, + worktree_base: str | None, +) -> WorkspaceOptions: + mode = requested or config.default or "none" + config_worktree = _validated_worktree(config.worktree) + config_path = config.worktree_path + config_branch = config.worktree_branch + config_base = config.worktree_base + if mode in {"none", "auto"}: + resolved_mode = mode + else: + preset = config.presets.get(mode) + if preset is None: + raise ValidationError(f"unknown workspace preset {mode!r}") + if preset.mode not in {"none", "auto"}: + raise ValidationError(f"workspace preset {mode!r} has unsupported mode {preset.mode!r}") + resolved_mode = preset.mode + config_worktree = _validated_worktree(preset.worktree) or config_worktree + config_path = preset.worktree_path or config_path + config_branch = preset.worktree_branch or config_branch + config_base = preset.worktree_base or config_base + return WorkspaceOptions( + mode=mode, + resolved_mode=resolved_mode, + worktree=worktree or config_worktree or "none", + worktree_path=worktree_path or config_path, + worktree_branch=worktree_branch or config_branch, + worktree_base=worktree_base or config_base, + ) + + +def _validated_worktree(value: str | None) -> WorktreeMode | None: + if value is None: + return None + if value not in {"none", "create"}: + raise ValidationError(f"unsupported workspace worktree mode {value!r}") + return cast(WorktreeMode, value) + + +def _setup_policy(setup: WorkspaceSetupMode, policy: RuntimePolicy, *, has_script: bool) -> str: + if not has_script: + return "no_script" + if setup == "skip": + return "skipped" + if setup == "run": + return "explicit" + if policy.allow_workspace_setup: + return "trusted" + return "not_allowed" + + +def _plan_worktree( + *, + mode: WorktreeMode, + input_cwd: Path, + source_repo: Path | None, + name: str, + path: str | None, + branch: str | None, + base: str | None, +) -> tuple[WorktreeView, Path]: + if mode == "none": + return WorktreeView(mode="none", state="disabled"), input_cwd + if source_repo is None: + raise ValidationError("--worktree create requires --cwd inside a git repository") + worktree_path, branch_name, base_ref = _worktree_values( + source_repo=source_repo, name=name, path=path, branch=branch, base=base + ) + head = _git(["rev-parse", "--short", base_ref], cwd=source_repo) + _check_branch_available(source_repo, branch_name) + return ( + WorktreeView( + mode="create", + state="planned", + path=str(worktree_path), + branch=branch_name, + base=base_ref, + head=head, + source_repo=str(source_repo), + created=False, + ), + worktree_path, + ) + + +async def _prepare_worktree( + *, + mode: WorktreeMode, + input_cwd: Path, + source_repo: Path | None, + name: str, + path: str | None, + branch: str | None, + base: str | None, +) -> tuple[WorktreeView, Path]: + if mode == "none": + return WorktreeView(mode="none", state="disabled"), input_cwd + if source_repo is None: + raise ValidationError("--worktree create requires --cwd inside a git repository") + return await asyncio.to_thread( + _create_worktree, + source_repo=source_repo, + name=name, + path=path, + branch=branch, + base=base, + ) + + +def _create_worktree( + *, + source_repo: Path, + name: str, + path: str | None, + branch: str | None, + base: str | None, +) -> tuple[WorktreeView, Path]: + worktree_path, branch_name, base_ref = _worktree_values( + source_repo=source_repo, name=name, path=path, branch=branch, base=base + ) + head = _git(["rev-parse", "--short", base_ref], cwd=source_repo) + _check_branch_available(source_repo, branch_name) + if worktree_path.exists(): + raise ValidationError(f"worktree path already exists: {worktree_path}") + worktree_path.parent.mkdir(parents=True, exist_ok=True) + branch_exists = _git_ok( + ["show-ref", "--verify", "--quiet", f"refs/heads/{branch_name}"], source_repo + ) + args = ["worktree", "add"] + if branch_exists: + args += [str(worktree_path), branch_name] + else: + args += ["-b", branch_name, str(worktree_path), base_ref] + _git(args, cwd=source_repo) + return ( + WorktreeView( + mode="create", + state="created", + path=str(worktree_path), + branch=branch_name, + base=base_ref, + head=head, + source_repo=str(source_repo), + created=True, + ), + worktree_path, + ) + + +def _worktree_values( + *, source_repo: Path, name: str, path: str | None, branch: str | None, base: str | None +) -> tuple[Path, str, str]: + name_slug = _slug(name) + repo_slug = _slug(source_repo.name) + worktree_path = _absolute(Path(path)) if path else worktree_root_path() / repo_slug / name_slug + branch_name = branch or f"dispatch/{name_slug}" + base_ref = base or "HEAD" + return worktree_path, branch_name, base_ref + + +def _check_branch_available(source_repo: Path, branch: str) -> None: + for entry in _worktree_list(source_repo): + if entry.get("branch") == f"refs/heads/{branch}": + path = entry.get("worktree", "unknown") + raise ValidationError(f"branch {branch!r} is already checked out in worktree {path}") + + +def _worktree_list(source_repo: Path) -> list[dict[str, str]]: + raw = _git(["worktree", "list", "--porcelain"], cwd=source_repo) + entries: list[dict[str, str]] = [] + current: dict[str, str] = {} + for line in raw.splitlines(): + if not line: + if current: + entries.append(current) + current = {} + continue + key, _, value = line.partition(" ") + current[key] = value + if current: + entries.append(current) + return entries + + +def _git_ok(args: list[str], cwd: Path) -> bool: + return ( + subprocess.run( + ["git", *args], + cwd=cwd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ).returncode + == 0 + ) + + +def _git(args: list[str], *, cwd: Path) -> str: + proc = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=False) + if proc.returncode != 0: + detail = proc.stderr.strip() or proc.stdout.strip() + raise ValidationError(f"git {' '.join(args)} failed in {cwd}: {detail}") + return proc.stdout.strip() + + +def _slug(value: str) -> str: + slug = _SLUG_RE.sub("-", value.strip().lower()).strip("-._") + return slug or "lane" + + +def _find_environment_file(input_cwd: Path, repo_root: Path | None) -> Path | None: + roots = [repo_root] if repo_root is not None else [input_cwd, *input_cwd.parents] + for root in roots: + candidate = root / _ENV_PATH + if candidate.is_file(): + return candidate + return None + + +def _find_repo_root(cwd: Path) -> Path | None: + for path in (cwd, *cwd.parents): + if (path / ".git").exists(): + return path + return None + + +def _load_environment(path: Path) -> WorkspaceEnvironmentView: + try: + with path.open("rb") as f: + raw = tomllib.load(f) + except tomllib.TOMLDecodeError as exc: + raise ValidationError(f"invalid workspace environment {path}: {exc}") from exc + except OSError as exc: + raise ValidationError(f"cannot read workspace environment {path}: {exc}") from exc + if not isinstance(raw, dict): + raise ValidationError(f"invalid workspace environment {path}: expected table") + + setup = raw.get("setup") + cleanup = raw.get("cleanup") + return WorkspaceEnvironmentView( + version=raw.get("version") if isinstance(raw.get("version"), int) else None, + name=raw.get("name") if isinstance(raw.get("name"), str) else None, + setup_script=_script(setup), + cleanup_script=_script(cleanup), + unknown_keys=sorted(str(k) for k in raw if k not in _TOP_LEVEL_KEYS), + ) + + +def _script(value: object) -> str | None: + if not isinstance(value, dict): + return None + script = value.get("script") + return script if isinstance(script, str) else None + + +async def _run_setup( + *, script: str, cwd: Path, policy_name: str, timeout_seconds: int +) -> WorkspaceSetupView: + started = time.monotonic() + try: + proc = await asyncio.create_subprocess_shell( + script, + cwd=str(cwd), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except OSError as exc: + raise ValidationError(f"failed to start workspace setup {script!r}: {exc}") from exc + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_seconds) + except TimeoutError as exc: + proc.kill() + await proc.wait() + raise ValidationError( + f"workspace setup timed out after {timeout_seconds}s: {script}" + ) from exc + + duration_ms = int((time.monotonic() - started) * 1000) + stdout_tail = _decode_tail(stdout) + stderr_tail = _decode_tail(stderr) + if proc.returncode != 0: + raise ValidationError( + f"workspace setup failed with exit {proc.returncode}: {script}" + + (f"\nstderr: {stderr_tail}" if stderr_tail else "") + ) + return WorkspaceSetupView( + policy=policy_name, + ran=True, + script=script, + cwd=str(cwd), + exit_code=proc.returncode, + duration_ms=duration_ms, + stdout_tail=stdout_tail, + stderr_tail=stderr_tail, + ) + + +def _decode_tail(data: bytes) -> str: + text = data.decode("utf-8", errors="replace") + return text[-_OUTPUT_TAIL_CHARS:] if len(text) > _OUTPUT_TAIL_CHARS else text + + +def _view( + *, + mode: str, + resolved_mode: str, + state: WorkspaceState, + input_cwd: Path, + effective_cwd: Path, + setup_view: WorkspaceSetupView, + repo_root: Path | None = None, + environment_file: Path | None = None, + environment: WorkspaceEnvironmentView | None = None, + worktree_view: WorktreeView | None = None, +) -> WorkspaceView: + return WorkspaceView( + mode=mode, + resolved_mode=resolved_mode, + state=state, + input_cwd=str(input_cwd), + repo_root=str(repo_root) if repo_root is not None else None, + effective_cwd=str(effective_cwd), + environment_file=str(environment_file) if environment_file is not None else None, + environment=environment, + setup=setup_view, + worktree=worktree_view or WorktreeView(state="disabled"), + ) + + +def _absolute(path: Path) -> Path: + expanded = path.expanduser() + return expanded if expanded.is_absolute() else (Path.cwd() / expanded).resolve() diff --git a/src/outfitter/dispatch/doctor.py b/src/outfitter/dispatch/doctor.py index 19db6e2..408ce41 100644 --- a/src/outfitter/dispatch/doctor.py +++ b/src/outfitter/dispatch/doctor.py @@ -304,6 +304,7 @@ def _registry_check() -> DoctorCheck: "lane_snapshots", "model_catalog", "lane_model_settings", + "lane_runtime_settings", } data.update( { diff --git a/src/outfitter/dispatch/registry/models.py b/src/outfitter/dispatch/registry/models.py index 79e7eb2..cc1737f 100644 --- a/src/outfitter/dispatch/registry/models.py +++ b/src/outfitter/dispatch/registry/models.py @@ -7,6 +7,15 @@ from pydantic import BaseModel, Field, TypeAdapter +from outfitter.dispatch.client.models import ( + ApprovalPolicy, + ApprovalsReviewer, + Effort, + Personality, + ReasoningSummary, + ThreadSandbox, +) + LaneSource = Literal["own", "attached"] LaneStatus = Literal["idle", "busy", "waiting_approval", "archived", "error", "unknown"] TurnRuntimeStatus = Literal["started", "completed", "failed"] @@ -50,6 +59,20 @@ class LaneModelSettings(BaseModel): updated_at: str +class LaneRuntimeSettings(BaseModel): + lane: str + sandbox: ThreadSandbox = "read-only" + approval_policy: ApprovalPolicy = "never" + approvals_reviewer: ApprovalsReviewer | None = None + effort: Effort | None = None + summary: ReasoningSummary | None = None + model: str | None = None + service_tier: str | None = None + output_schema: dict[str, object] | None = None + personality: Personality | None = None + updated_at: str + + class Lane(BaseModel): """A managed Codex thread — one row of the ``lanes`` table.""" diff --git a/src/outfitter/dispatch/registry/store.py b/src/outfitter/dispatch/registry/store.py index 424408c..8d968c4 100644 --- a/src/outfitter/dispatch/registry/store.py +++ b/src/outfitter/dispatch/registry/store.py @@ -22,6 +22,7 @@ Guard, Lane, LaneModelSettings, + LaneRuntimeSettings, LaneSource, LaneStatus, LaneSync, @@ -34,7 +35,7 @@ from .refs import BASE58BTC_ALPHABET, CODEX_REF_SOURCE, codex_ref_payload, make_ref Clock = Callable[[], datetime] -SCHEMA_VERSION = 6 +SCHEMA_VERSION = 7 _QUEUED_MESSAGES_SCHEMA = """ CREATE TABLE IF NOT EXISTS queued_messages ( @@ -157,6 +158,20 @@ def _utcnow() -> datetime: updated_at TEXT NOT NULL, FOREIGN KEY(lane) REFERENCES lanes(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS lane_runtime_settings ( + lane TEXT PRIMARY KEY, + sandbox TEXT NOT NULL DEFAULT 'read-only', + approval_policy TEXT NOT NULL DEFAULT 'never', + approvals_reviewer TEXT, + effort TEXT, + summary TEXT, + model TEXT, + service_tier TEXT, + output_schema TEXT, + personality TEXT, + updated_at TEXT NOT NULL, + FOREIGN KEY(lane) REFERENCES lanes(id) ON DELETE CASCADE +); """ @@ -220,6 +235,8 @@ async def _migrate(self, user_version: int) -> None: if user_version < 6: await self._prune_orphan_lane_children() await self._ensure_queued_messages_foreign_key() + if user_version < 7: + await self._ensure_lane_runtime_settings_table() async def _ensure_ref_columns(self) -> None: async with self._conn.execute("PRAGMA table_info(lanes)") as cur: @@ -278,11 +295,32 @@ async def _ensure_model_registry_tables(self) -> None: """ ) + async def _ensure_lane_runtime_settings_table(self) -> None: + await self._conn.executescript( + """ + CREATE TABLE IF NOT EXISTS lane_runtime_settings ( + lane TEXT PRIMARY KEY, + sandbox TEXT NOT NULL DEFAULT 'read-only', + approval_policy TEXT NOT NULL DEFAULT 'never', + approvals_reviewer TEXT, + effort TEXT, + summary TEXT, + model TEXT, + service_tier TEXT, + output_schema TEXT, + personality TEXT, + updated_at TEXT NOT NULL, + FOREIGN KEY(lane) REFERENCES lanes(id) ON DELETE CASCADE + ); + """ + ) + async def _prune_orphan_lane_children(self) -> None: for table in ( "lane_sync_sources", "lane_snapshots", "lane_model_settings", + "lane_runtime_settings", "queued_messages", ): await self._conn.execute( @@ -860,6 +898,42 @@ async def get_lane_model_settings_many( settings = (_row_to_lane_model_settings(row) for row in rows) return {item.lane: item for item in settings} + # --- lane runtime settings ------------------------------------------------- + + async def upsert_lane_runtime_settings(self, settings: LaneRuntimeSettings) -> None: + await self._conn.execute( + "INSERT INTO lane_runtime_settings (lane, sandbox, approval_policy, " + "approvals_reviewer, effort, summary, model, service_tier, output_schema, " + "personality, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT(lane) DO UPDATE SET sandbox = excluded.sandbox, " + "approval_policy = excluded.approval_policy, " + "approvals_reviewer = excluded.approvals_reviewer, effort = excluded.effort, " + "summary = excluded.summary, model = excluded.model, " + "service_tier = excluded.service_tier, output_schema = excluded.output_schema, " + "personality = excluded.personality, updated_at = excluded.updated_at", + ( + settings.lane, + settings.sandbox, + settings.approval_policy, + settings.approvals_reviewer, + settings.effort, + settings.summary, + settings.model, + settings.service_tier, + json.dumps(settings.output_schema) if settings.output_schema is not None else None, + settings.personality, + settings.updated_at, + ), + ) + await self._conn.commit() + + async def get_lane_runtime_settings(self, lane_id: str) -> LaneRuntimeSettings | None: + async with self._conn.execute( + "SELECT * FROM lane_runtime_settings WHERE lane = ?", (lane_id,) + ) as cur: + row = await cur.fetchone() + return _row_to_lane_runtime_settings(row) if row is not None else None + # --- triggers ------------------------------------------------------------- async def add_trigger(self, trigger: Trigger) -> Trigger: @@ -1036,6 +1110,13 @@ def _row_to_lane_model_settings(row: aiosqlite.Row) -> LaneModelSettings: return LaneModelSettings.model_validate(_row_dict(row)) +def _row_to_lane_runtime_settings(row: aiosqlite.Row) -> LaneRuntimeSettings: + data = _row_dict(row) + raw_schema = data["output_schema"] + data["output_schema"] = json.loads(str(raw_schema)) if raw_schema else None + return LaneRuntimeSettings.model_validate(data) + + def _row_to_trigger(row: aiosqlite.Row) -> Trigger: data = _row_dict(row) last_fired = data["last_fired_at"] diff --git a/tests/core/test_examples.py b/tests/core/test_examples.py index 47eee74..cef8f73 100644 --- a/tests/core/test_examples.py +++ b/tests/core/test_examples.py @@ -20,6 +20,7 @@ async def test_registry_has_the_v1_ops() -> None: "show", "lane-rename", "transcript", + "history", "watch", "sync", "roster", diff --git a/tests/core/test_handlers.py b/tests/core/test_handlers.py index af27ca7..2769cc9 100644 --- a/tests/core/test_handlers.py +++ b/tests/core/test_handlers.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import subprocess from collections.abc import AsyncIterator from pathlib import Path @@ -13,6 +14,12 @@ from outfitter.dispatch.client.errors import TransportError from outfitter.dispatch.client.events import LaneIdle, TurnFailed, TurnStarted from outfitter.dispatch.client.models import ( + ApprovalPolicy, + ApprovalsReviewer, + Effort, + Personality, + ReasoningSummary, + SandboxPolicy, ThreadGoal, ThreadInfo, ThreadSearchMatch, @@ -36,6 +43,7 @@ GoalClearInput, GoalGetInput, GoalSetInput, + HistoryInput, LaneInput, LaneRenameInput, LaneSyncInput, @@ -247,6 +255,100 @@ async def test_new_lane_no_send_registers_without_turn(store: Registry, tmp_path assert not any(name == "turn_start" for name, _ in client.calls) +async def test_send_reuses_runtime_settings_from_no_send_lane( + store: Registry, tmp_path: Path +) -> None: + client = FakeLaneClient() + ctx = make_ctx(store, client) + out = await handlers.new_lane( + NewInput( + name="worker", + cwd=str(tmp_path), + text="later", + send=False, + sandbox="workspace-write", + approval_policy="on-request", + approvals_reviewer="user", + effort="low", + summary="concise", + model="gpt-5.5", + service_tier="priority", + personality="pragmatic", + ), + ctx, + ) + client.calls.clear() + + await handlers.send(LaneTextInput(lane=out.ref, text="start now"), ctx) + + call = next(kw for name, kw in client.calls if name == "turn_start") + assert call["sandbox_policy"] == {"type": "workspaceWrite"} + assert call["approval_policy"] == "on-request" + assert call["approvals_reviewer"] == "user" + assert call["effort"] == "low" + assert call["summary"] == "concise" + assert call["model"] == "gpt-5.5" + assert call["service_tier"] == "priority" + assert call["personality"] == "pragmatic" + + +async def test_queue_reuses_runtime_settings_from_no_send_lane( + store: Registry, tmp_path: Path +) -> None: + client = FakeLaneClient() + ctx = make_ctx(store, client) + out = await handlers.new_lane( + NewInput( + name="queued-worker", + cwd=str(tmp_path), + text="later", + send=False, + sandbox="workspace-write", + approval_policy="on-request", + ), + ctx, + ) + client.calls.clear() + + ack = await handlers.send_message(SendInput(lane=out.ref, text="queued", mode="queue"), ctx) + + assert ack.op == "queue" + call = next(kw for name, kw in client.calls if name == "turn_start") + assert call["sandbox_policy"] == {"type": "workspaceWrite"} + assert call["approval_policy"] == "on-request" + + +async def test_interject_reuses_runtime_settings_from_no_send_lane( + store: Registry, tmp_path: Path +) -> None: + client = FakeLaneClient() + ctx = make_ctx(store, client) + out = await handlers.new_lane( + NewInput( + name="interject-worker", + cwd=str(tmp_path), + text="later", + send=False, + sandbox="workspace-write", + approval_policy="on-request", + ), + ctx, + ) + await store.set_active_turn(out.id, "turn-1") + await store.update_lane_status(out.id, "busy") + client.calls.clear() + + ack = await handlers.send_message( + SendInput(lane=out.ref, text="replace", mode="interject"), ctx + ) + + assert ack.op == "interject" + assert any(name == "turn_interrupt" and kw["turn_id"] == "turn-1" for name, kw in client.calls) + call = next(kw for name, kw in client.calls if name == "turn_start") + assert call["sandbox_policy"] == {"type": "workspaceWrite"} + assert call["approval_policy"] == "on-request" + + async def test_new_lane_sets_native_goal_before_initial_turn( store: Registry, tmp_path: Path ) -> None: @@ -289,6 +391,45 @@ async def turn_start(self, *args: object, **kwargs: object) -> dict[str, object] raise TransportError("boom") +class _CompletingBeforeReturnClient(FakeLaneClient): + def __init__(self, store: Registry) -> None: + super().__init__() + self._store = store + + async def turn_start( + self, + thread_id: str, + text: str, + cwd: str, + approval_policy: ApprovalPolicy = "never", + approvals_reviewer: ApprovalsReviewer | None = None, + sandbox_policy: SandboxPolicy | None = None, + effort: Effort | None = None, + summary: ReasoningSummary | None = None, + model: str | None = None, + service_tier: str | None = None, + output_schema: dict[str, object] | None = None, + personality: Personality | None = None, + ) -> dict[str, object]: + await super().turn_start( + thread_id, + text, + cwd, + approval_policy=approval_policy, + approvals_reviewer=approvals_reviewer, + sandbox_policy=sandbox_policy, + effort=effort, + summary=summary, + model=model, + service_tier=service_tier, + output_schema=output_schema, + personality=personality, + ) + await self._store.record_turn_started(thread_id, "turn-race") + await self._store.record_turn_completed(thread_id, "turn-race") + return {} + + async def test_new_lane_initial_send_failure_leaves_lane_registered( store: Registry, tmp_path: Path ) -> None: @@ -320,6 +461,20 @@ async def test_send_resolves_by_handle(store: Registry) -> None: assert by_ref.lane == "lane-1" +async def test_send_does_not_overwrite_fast_completion_with_busy(store: Registry) -> None: + client = _CompletingBeforeReturnClient(store) + ctx = make_ctx(store, client) + await handlers.open_lane(OpenInput(name="beta"), ctx) + + await handlers.send(LaneTextInput(lane="@beta", text="fast"), ctx) + + detail = await handlers.show(ShowInput(lane="@beta"), ctx) + assert detail.status == "idle" + assert detail.active_turn_id is None + assert detail.latest_turn.id == "turn-race" + assert detail.latest_turn.status == "completed" + + async def test_send_failure_marks_latest_turn_error(store: Registry) -> None: client = _FailingTurnClient() ctx = make_ctx(store, client) @@ -569,6 +724,100 @@ async def test_transcript_reads_persisted_turn_items(store: Registry) -> None: assert out.items[0].text == "done" +async def test_history_overview_summarizes_managed_threads(store: Registry) -> None: + client = FakeLaneClient() + client.read_result = { + "thread": { + "id": "lane-1", + "turns": [ + { + "id": "t1", + "items": [ + {"id": "u1", "type": "userMessage", "text": "run status"}, + { + "id": "tool-1", + "type": "toolCall", + "toolName": "bash", + "text": "git status", + }, + { + "id": "file-1", + "type": "fileChange", + "path": "src/app.py", + "text": "edited app", + }, + ], + } + ], + } + } + ctx = make_ctx(store, client) + await handlers.open_lane(OpenInput(name="alpha", cwd="/tmp/no-such-history-worktree"), ctx) + + out = await handlers.history(HistoryInput(), ctx) + + assert out.mode == "overview" + assert len(out.threads) == 1 + summary = out.threads[0] + assert summary.ref == "0BGeK1" + assert summary.turns == 1 + assert summary.items == 3 + assert summary.messages == 1 + assert summary.tool_calls == 1 + assert summary.unique_tools == ["bash"] + assert summary.files_changed_count == 1 + assert summary.files_changed[0].path == "src/app.py" + assert summary.transcript_bytes is not None + + +async def test_history_thread_views_filter_items_and_rollups(store: Registry) -> None: + client = FakeLaneClient() + client.read_result = { + "thread": { + "id": "lane-1", + "turns": [ + { + "id": "t1", + "items": [ + {"id": "a1", "type": "agentMessage", "text": "I will inspect."}, + { + "id": "b1", + "type": "toolCall", + "toolName": "bash", + "text": "git status", + }, + { + "id": "p1", + "type": "toolCall", + "toolName": "apply_patch", + "path": "src/app.py", + "text": "patch src/app.py", + }, + ], + } + ], + } + } + ctx = make_ctx(store, client) + await handlers.open_lane(OpenInput(name="alpha"), ctx) + + summary = await handlers.history(HistoryInput(lane="@alpha"), ctx) + tools = await handlers.history(HistoryInput(lane="@alpha", view="tools"), ctx) + files = await handlers.history(HistoryInput(lane="@alpha", view="files"), ctx) + items = await handlers.history( + HistoryInput(lane="@alpha", view="items", tool="bash", raw=True), ctx + ) + + assert summary.mode == "summary" + assert summary.thread is not None + assert summary.thread.tool_calls == 2 + assert [tool.tool for tool in tools.tools] == ["bash", "apply_patch"] + assert files.files[0].path == "src/app.py" + assert len(items.items) == 1 + assert items.items[0].tool == "bash" + assert items.items[0].raw is not None + + async def test_watch_collects_bounded_raw_events(store: Registry) -> None: client = FakeLaneClient() client.raw_log = [ @@ -1463,3 +1712,203 @@ async def test_plan_new_lane_reports_stage_without_writing(store: Registry, tmp_ assert set(plan.stage.parts) == {"goal", "prompt"} assert plan.stage.session_dir is None # dry-run: ref unknown, nothing written assert not (cwd / ".agents").exists() + + +async def test_plan_new_lane_reports_workspace_without_setup( + store: Registry, tmp_path: Path +) -> None: + repo = tmp_path / "repo" + env_dir = repo / ".codex" / "environments" + env_dir.mkdir(parents=True) + (repo / ".git").mkdir() + (env_dir / "environment.toml").write_text( + """ +version = 1 +name = "repo" + +[setup] +script = "touch SHOULD_NOT_EXIST" +""" + ) + ctx = make_ctx(store) + + plan = await handlers.plan_new_lane( + NewInput(name="worker", cwd=str(repo), workspace="auto"), ctx + ) + + assert plan.cwd == str(repo) + assert plan.workspace.state == "discovered" + assert plan.workspace.environment is not None + assert plan.workspace.environment.name == "repo" + assert plan.workspace.setup.ran is False + assert not (repo / "SHOULD_NOT_EXIST").exists() + + +async def test_new_lane_workspace_auto_uses_effective_repo_cwd( + store: Registry, tmp_path: Path +) -> None: + repo = tmp_path / "repo" + nested = repo / "nested" + env_dir = repo / ".codex" / "environments" + env_dir.mkdir(parents=True) + nested.mkdir(parents=True) + (repo / ".git").mkdir() + (env_dir / "environment.toml").write_text('version = 1\nname = "repo"\n') + client = FakeLaneClient() + ctx = make_ctx(store, client) + + out = await handlers.new_lane( + NewInput(name="worker", cwd=str(nested), workspace="auto", send=False), ctx + ) + + assert out.cwd == str(repo) + assert out.workspace.effective_cwd == str(repo) + assert out.workspace.repo_root == str(repo) + assert any(name == "thread_start" and kw["cwd"] == str(repo) for name, kw in client.calls) + + +async def test_new_lane_workspace_setup_requires_policy_or_explicit_run( + store: Registry, tmp_path: Path +) -> None: + repo = tmp_path / "repo" + env_dir = repo / ".codex" / "environments" + env_dir.mkdir(parents=True) + (repo / ".git").mkdir() + (env_dir / "environment.toml").write_text( + """ +version = 1 +name = "repo" + +[setup] +script = "printf ran > setup.txt" +""" + ) + client = FakeLaneClient() + ctx = make_ctx(store, client) + + out = await handlers.new_lane( + NewInput(name="worker", cwd=str(repo), workspace="auto", send=False), ctx + ) + + assert out.workspace.setup.ran is False + assert out.workspace.setup.policy == "not_allowed" + assert not (repo / "setup.txt").exists() + + +async def test_new_lane_workspace_setup_runs_with_explicit_run( + store: Registry, tmp_path: Path +) -> None: + repo = tmp_path / "repo" + env_dir = repo / ".codex" / "environments" + env_dir.mkdir(parents=True) + (repo / ".git").mkdir() + (env_dir / "environment.toml").write_text( + """ +version = 1 +name = "repo" + +[setup] +script = "printf ran > setup.txt" +""" + ) + client = FakeLaneClient() + ctx = make_ctx(store, client) + + out = await handlers.new_lane( + NewInput( + name="worker", + cwd=str(repo), + workspace="auto", + workspace_setup="run", + send=False, + ), + ctx, + ) + + assert out.workspace.state == "setup_completed" + assert out.workspace.setup.ran is True + assert (repo / "setup.txt").read_text() == "ran" + + +async def test_new_lane_workspace_setup_failure_prevents_thread_start( + store: Registry, tmp_path: Path +) -> None: + repo = tmp_path / "repo" + env_dir = repo / ".codex" / "environments" + env_dir.mkdir(parents=True) + (repo / ".git").mkdir() + (env_dir / "environment.toml").write_text( + """ +version = 1 +name = "repo" + +[setup] +script = "exit 4" +""" + ) + client = FakeLaneClient() + ctx = make_ctx(store, client) + + with pytest.raises(ValidationError, match="workspace setup failed"): + await handlers.new_lane( + NewInput( + name="worker", + cwd=str(repo), + workspace="auto", + workspace_setup="run", + send=False, + ), + ctx, + ) + + assert not any(name == "thread_start" for name, _ in client.calls) + + +async def test_new_lane_dispatch_created_worktree_is_effective_cwd_for_stage_and_thread( + store: Registry, tmp_path: Path +) -> None: + repo = _git_repo_for_worktree(tmp_path / "repo") + pkt = _stage_packet(tmp_path) + worktree_path = tmp_path / "worker-wt" + client = FakeLaneClient() + ctx = make_ctx(store, client) + + out = await handlers.new_lane( + NewInput( + name="worker", + cwd=str(repo), + packet=str(pkt), + stage="all", + send=False, + worktree="create", + worktree_path=str(worktree_path), + worktree_branch="dispatch/worker", + ), + ctx, + ) + + assert out.cwd == str(worktree_path) + assert out.workspace.worktree.state == "created" + assert out.workspace.worktree.created is True + assert out.workspace.worktree.branch == "dispatch/worker" + assert any( + name == "thread_start" and kw["cwd"] == str(worktree_path) for name, kw in client.calls + ) + assert out.staged.session_dir == str(worktree_path / ".agents" / "sessions" / out.ref) + assert (worktree_path / ".agents" / "sessions" / out.ref / "packet" / "goal.md").is_file() + + +def _git_repo_for_worktree(path: Path) -> Path: + path.mkdir() + _run_git_for_worktree(path, "init", "-q") + _run_git_for_worktree(path, "config", "user.email", "dispatch@example.test") + _run_git_for_worktree(path, "config", "user.name", "Dispatch Test") + (path / "README.md").write_text("hi\n") + _run_git_for_worktree(path, "add", "README.md") + _run_git_for_worktree(path, "commit", "-qm", "init") + return path + + +def _run_git_for_worktree(cwd: Path, *args: str) -> str: + proc = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=True) + return proc.stdout.strip() diff --git a/tests/core/test_new_config.py b/tests/core/test_new_config.py index b6f7b14..5a8fb7f 100644 --- a/tests/core/test_new_config.py +++ b/tests/core/test_new_config.py @@ -33,6 +33,20 @@ def test_resolve_new_merges_defaults_presets_and_cli( [presets.fast] effort = "low" model = "fast-model" + +[workspace] +default = "auto" +worktree = "create" +worktree_path = ".dispatch/wt-default" +worktree_branch = "dispatch/default" +worktree_base = "main" + +[workspace.presets.athena] +mode = "auto" +worktree = "create" +worktree_path = ".dispatch/wt-athena" +worktree_branch = "dispatch/athena" +worktree_base = "origin/main" """ ) monkeypatch.chdir(tmp_path) @@ -51,6 +65,18 @@ def test_resolve_new_merges_defaults_presets_and_cli( assert resolved.settings.approval_policy == "on-request" assert resolved.settings.effort == "low" assert resolved.settings.model == "cli-model" + assert resolved.workspace.default == "auto" + assert resolved.workspace.worktree == "create" + assert resolved.workspace.worktree_path == str(repo / ".dispatch" / "wt-default") + assert resolved.workspace.worktree_branch == "dispatch/default" + assert resolved.workspace.worktree_base == "main" + assert resolved.workspace.presets["athena"].mode == "auto" + assert resolved.workspace.presets["athena"].worktree == "create" + assert resolved.workspace.presets["athena"].worktree_path == str( + repo / ".dispatch" / "wt-athena" + ) + assert resolved.workspace.presets["athena"].worktree_branch == "dispatch/athena" + assert resolved.workspace.presets["athena"].worktree_base == "origin/main" def test_resolve_new_ignores_runtime_policy_table(tmp_path: Path) -> None: diff --git a/tests/core/test_workspace.py b/tests/core/test_workspace.py new file mode 100644 index 0000000..679e6e7 --- /dev/null +++ b/tests/core/test_workspace.py @@ -0,0 +1,391 @@ +"""Workspace preflight discovery and setup policy.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from outfitter.dispatch.config import RuntimePolicy +from outfitter.dispatch.contracts.errors import ValidationError +from outfitter.dispatch.core.new_config import WorkspaceConfig +from outfitter.dispatch.core.workspace import plan_workspace, prepare_workspace + + +def test_plan_workspace_none_preserves_cwd(tmp_path: Path) -> None: + resolved = plan_workspace( + cwd=tmp_path, + name="worker", + requested="none", + setup="auto", + worktree="none", + worktree_path=None, + worktree_branch=None, + worktree_base=None, + config=WorkspaceConfig(), + policy=RuntimePolicy(), + ) + + assert resolved.effective_cwd == tmp_path + assert resolved.view.state == "disabled" + assert resolved.view.setup.ran is False + + +def test_plan_workspace_auto_reports_missing_metadata(tmp_path: Path) -> None: + resolved = plan_workspace( + cwd=tmp_path, + name="worker", + requested="auto", + setup="auto", + worktree="none", + worktree_path=None, + worktree_branch=None, + worktree_base=None, + config=WorkspaceConfig(), + policy=RuntimePolicy(), + ) + + assert resolved.effective_cwd == tmp_path + assert resolved.view.state == "not_found" + assert resolved.view.environment_file is None + assert resolved.view.setup.policy == "not_found" + + +def test_plan_workspace_parses_codex_environment(tmp_path: Path) -> None: + repo = tmp_path / "repo" + env_dir = repo / ".codex" / "environments" + env_dir.mkdir(parents=True) + (repo / ".git").mkdir() + (env_dir / "environment.toml").write_text( + """ +version = 1 +name = "athena-vault" +extra = "recorded" + +[setup] +script = "./.codex/hooks/workspace-bootstrap.sh" + +[cleanup] +script = "./.codex/hooks/workspace-teardown.sh" +""" + ) + + resolved = plan_workspace( + cwd=repo, + name="worker", + requested="auto", + setup="auto", + worktree="none", + worktree_path=None, + worktree_branch=None, + worktree_base=None, + config=WorkspaceConfig(), + policy=RuntimePolicy(), + ) + + assert resolved.effective_cwd == repo + assert resolved.view.state == "discovered" + assert resolved.view.repo_root == str(repo) + assert resolved.view.environment is not None + assert resolved.view.environment.name == "athena-vault" + assert resolved.view.environment.setup_script == "./.codex/hooks/workspace-bootstrap.sh" + assert resolved.view.environment.cleanup_script == "./.codex/hooks/workspace-teardown.sh" + assert resolved.view.environment.unknown_keys == ["extra"] + assert resolved.view.setup.policy == "not_allowed" + + +def test_plan_workspace_uses_named_preset(tmp_path: Path) -> None: + config = WorkspaceConfig.model_validate({"presets": {"athena": {"mode": "auto"}}}) + + resolved = plan_workspace( + cwd=tmp_path, + name="worker", + requested="athena", + setup="auto", + worktree="none", + worktree_path=None, + worktree_branch=None, + worktree_base=None, + config=config, + policy=RuntimePolicy(), + ) + + assert resolved.view.mode == "athena" + assert resolved.view.resolved_mode == "auto" + + +def test_plan_workspace_uses_configured_worktree_defaults( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo = _git_repo(tmp_path / "repo") + root = tmp_path / "dispatch-worktrees" + monkeypatch.setenv("DISPATCH_WORKTREE_ROOT", str(root)) + config = WorkspaceConfig.model_validate( + { + "default": "auto", + "worktree": "create", + "worktree_branch": "dispatch/from-config", + "worktree_base": "HEAD", + } + ) + + resolved = plan_workspace( + cwd=repo, + name="worker", + requested=None, + setup="auto", + worktree=None, + worktree_path=None, + worktree_branch=None, + worktree_base=None, + config=config, + policy=RuntimePolicy(), + ) + + assert resolved.view.mode == "auto" + assert resolved.view.worktree.mode == "create" + assert resolved.view.worktree.path == str(root / "repo" / "worker") + assert resolved.view.worktree.branch == "dispatch/from-config" + assert resolved.view.worktree.base == "HEAD" + + +def test_plan_workspace_preset_worktree_overrides_global_workspace_config( + tmp_path: Path, +) -> None: + repo = _git_repo(tmp_path / "repo") + config = WorkspaceConfig.model_validate( + { + "worktree": "none", + "worktree_branch": "dispatch/global", + "presets": { + "athena": { + "mode": "auto", + "worktree": "create", + "worktree_path": str(tmp_path / "athena-wt"), + "worktree_branch": "dispatch/athena", + "worktree_base": "HEAD", + } + }, + } + ) + + resolved = plan_workspace( + cwd=repo, + name="worker", + requested="athena", + setup="auto", + worktree=None, + worktree_path=None, + worktree_branch=None, + worktree_base=None, + config=config, + policy=RuntimePolicy(), + ) + + assert resolved.view.mode == "athena" + assert resolved.view.worktree.mode == "create" + assert resolved.view.worktree.path == str(tmp_path / "athena-wt") + assert resolved.view.worktree.branch == "dispatch/athena" + + +def test_plan_workspace_cli_worktree_overrides_workspace_config(tmp_path: Path) -> None: + repo = _git_repo(tmp_path / "repo") + config = WorkspaceConfig.model_validate( + { + "default": "auto", + "worktree": "create", + "worktree_branch": "dispatch/from-config", + } + ) + + resolved = plan_workspace( + cwd=repo, + name="worker", + requested=None, + setup="auto", + worktree="none", + worktree_path=None, + worktree_branch=None, + worktree_base=None, + config=config, + policy=RuntimePolicy(), + ) + + assert resolved.effective_cwd == repo + assert resolved.view.worktree.mode == "none" + assert resolved.view.worktree.state == "disabled" + + +def test_plan_workspace_rejects_invalid_environment_toml(tmp_path: Path) -> None: + repo = tmp_path / "repo" + env_dir = repo / ".codex" / "environments" + env_dir.mkdir(parents=True) + (repo / ".git").mkdir() + (env_dir / "environment.toml").write_text("[setup\n") + + with pytest.raises(ValidationError, match="invalid workspace environment"): + plan_workspace( + cwd=repo, + name="worker", + requested="auto", + setup="auto", + worktree="none", + worktree_path=None, + worktree_branch=None, + worktree_base=None, + config=WorkspaceConfig(), + policy=RuntimePolicy(), + ) + + +async def test_prepare_workspace_runs_setup_when_explicit(tmp_path: Path) -> None: + repo = tmp_path / "repo" + env_dir = repo / ".codex" / "environments" + env_dir.mkdir(parents=True) + (repo / ".git").mkdir() + (env_dir / "environment.toml").write_text( + """ +version = 1 +name = "repo" + +[setup] +script = "printf setup-ok" +""" + ) + + resolved = await prepare_workspace( + cwd=repo, + name="worker", + requested="auto", + setup="run", + worktree="none", + worktree_path=None, + worktree_branch=None, + worktree_base=None, + config=WorkspaceConfig(), + policy=RuntimePolicy(), + ) + + assert resolved.view.state == "setup_completed" + assert resolved.view.setup.ran is True + assert resolved.view.setup.policy == "explicit" + assert resolved.view.setup.stdout_tail == "setup-ok" + + +async def test_prepare_workspace_rejects_failing_setup(tmp_path: Path) -> None: + repo = tmp_path / "repo" + env_dir = repo / ".codex" / "environments" + env_dir.mkdir(parents=True) + (repo / ".git").mkdir() + (env_dir / "environment.toml").write_text( + """ +version = 1 +name = "repo" + +[setup] +script = "printf nope >&2; exit 7" +""" + ) + + with pytest.raises(ValidationError, match="workspace setup failed"): + await prepare_workspace( + cwd=repo, + name="worker", + requested="auto", + setup="run", + worktree="none", + worktree_path=None, + worktree_branch=None, + worktree_base=None, + config=WorkspaceConfig(), + policy=RuntimePolicy(), + ) + + +def test_plan_workspace_reports_global_dispatch_worktree( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo = _git_repo(tmp_path / "repo") + root = tmp_path / "dispatch-worktrees" + monkeypatch.setenv("DISPATCH_WORKTREE_ROOT", str(root)) + + resolved = plan_workspace( + cwd=repo, + name="[repo] Lane A", + requested="none", + setup="auto", + worktree="create", + worktree_path=None, + worktree_branch=None, + worktree_base=None, + config=WorkspaceConfig(), + policy=RuntimePolicy(), + ) + + assert resolved.effective_cwd == root / "repo" / "repo-lane-a" + assert resolved.view.worktree.mode == "create" + assert resolved.view.worktree.state == "planned" + assert resolved.view.worktree.branch == "dispatch/repo-lane-a" + assert resolved.view.worktree.created is False + assert not resolved.effective_cwd.exists() + + +async def test_prepare_workspace_creates_git_worktree(tmp_path: Path) -> None: + repo = _git_repo(tmp_path / "repo") + target = tmp_path / "wt" + + resolved = await prepare_workspace( + cwd=repo, + name="lane", + requested="none", + setup="auto", + worktree="create", + worktree_path=str(target), + worktree_branch="dispatch/lane", + worktree_base=None, + config=WorkspaceConfig(), + policy=RuntimePolicy(), + ) + + assert resolved.effective_cwd == target + assert resolved.view.worktree.state == "created" + assert resolved.view.worktree.created is True + assert (target / "README.md").read_text() == "hi\n" + assert _run_git(target, "branch", "--show-current") == "dispatch/lane" + + +async def test_prepare_workspace_rejects_branch_checked_out_elsewhere(tmp_path: Path) -> None: + repo = _git_repo(tmp_path / "repo") + first = tmp_path / "first" + _run_git(repo, "worktree", "add", "-b", "dispatch/lane", str(first), "HEAD") + + with pytest.raises(ValidationError, match="already checked out"): + await prepare_workspace( + cwd=repo, + name="lane", + requested="none", + setup="auto", + worktree="create", + worktree_path=str(tmp_path / "second"), + worktree_branch="dispatch/lane", + worktree_base=None, + config=WorkspaceConfig(), + policy=RuntimePolicy(), + ) + + +def _git_repo(path: Path) -> Path: + path.mkdir() + _run_git(path, "init", "-q") + _run_git(path, "config", "user.email", "dispatch@example.test") + _run_git(path, "config", "user.name", "Dispatch Test") + (path / "README.md").write_text("hi\n") + _run_git(path, "add", "README.md") + _run_git(path, "commit", "-qm", "init") + return path + + +def _run_git(cwd: Path, *args: str) -> str: + proc = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=True) + return proc.stdout.strip() diff --git a/tests/fixtures/registry/builders.py b/tests/fixtures/registry/builders.py index 2d3ad78..6f66633 100644 --- a/tests/fixtures/registry/builders.py +++ b/tests/fixtures/registry/builders.py @@ -10,6 +10,7 @@ from outfitter.dispatch.registry.models import ( LaneModelSettings, + LaneRuntimeSettings, ModelCatalogEntry, ServiceTierEntry, ) @@ -72,3 +73,23 @@ def lane_model_settings( service_tier_source="dispatch", updated_at=updated_at or fixed_now_iso(), ) + + +def lane_runtime_settings( + *, + lane: str = "L1", + updated_at: str | None = None, +) -> LaneRuntimeSettings: + return LaneRuntimeSettings( + lane=lane, + sandbox="workspace-write", + approval_policy="on-request", + approvals_reviewer="user", + effort="low", + summary="concise", + model="gpt-5.5", + service_tier="priority", + output_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}}, + personality="pragmatic", + updated_at=updated_at or fixed_now_iso(), + ) diff --git a/tests/registry/test_store.py b/tests/registry/test_store.py index 6770b27..9cb8396 100644 --- a/tests/registry/test_store.py +++ b/tests/registry/test_store.py @@ -14,7 +14,11 @@ from outfitter.dispatch.registry.models import LaneSync from outfitter.dispatch.registry.refs import BASE58BTC_ALPHABET, codex_ref_payload from outfitter.dispatch.registry.store import SCHEMA_VERSION, Registry -from tests.fixtures.registry.builders import lane_model_settings, model_catalog_entry +from tests.fixtures.registry.builders import ( + lane_model_settings, + lane_runtime_settings, + model_catalog_entry, +) def _clock() -> datetime: @@ -262,6 +266,16 @@ async def test_model_catalog_and_lane_model_settings_roundtrip(store: Registry) assert await store.get_lane_model_settings_many([lane.id, "missing"]) == {lane.id: settings} +async def test_lane_runtime_settings_roundtrip(store: Registry) -> None: + lane = await store.add_lane(id="L1", handle="@alpha", source="own") + settings = lane_runtime_settings(lane=lane.id, updated_at=store.now_iso()) + + await store.upsert_lane_runtime_settings(settings) + + assert await store.get_lane_runtime_settings(lane.id) == settings + assert await store.get_lane_runtime_settings("missing") is None + + async def test_get_missing_lane_raises_not_found(store: Registry) -> None: assert await store.find_lane("nope") is None with pytest.raises(NotFoundError): diff --git a/tests/surfaces/test_derive_cli.py b/tests/surfaces/test_derive_cli.py index 4394674..31a512b 100644 --- a/tests/surfaces/test_derive_cli.py +++ b/tests/surfaces/test_derive_cli.py @@ -442,6 +442,72 @@ def test_new_dry_run_routes_to_new_plan_op() -> None: assert "dry_run" not in params # routing flag, not an op field +def test_history_routes_to_history_op_with_optional_lane_and_filters() -> 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)) + return { + "mode": "overview", + "threads": [], + "thread": None, + "items": [], + "tools": [], + "files": [], + } + + app = derive_cli(REGISTRY, invoke) + + overview = runner.invoke(app, ["history"]) + items = runner.invoke( + app, + [ + "history", + "@lane", + "--view", + "items", + "--type", + "tool", + "--tool", + "bash", + "--grep", + "git", + "--raw", + "--limit", + "5", + ], + ) + + assert overview.exit_code == 0 + assert items.exit_code == 0 + assert calls == [ + ( + "history", + { + "lane": None, + "view": "auto", + "item_type": None, + "tool": None, + "grep": None, + "raw": False, + "limit": 50, + }, + ), + ( + "history", + { + "lane": "@lane", + "view": "items", + "item_type": "tool", + "tool": "bash", + "grep": "git", + "raw": True, + "limit": 5, + }, + ), + ] + + def test_new_goal_file_dash_reads_stdin_into_inline_goal() -> None: captured: dict[str, object] = {} app = derive_cli(REGISTRY, _capture_invoke(captured)) @@ -492,7 +558,30 @@ def test_new_stage_and_inline_pass_through() -> None: captured: dict[str, object] = {} app = derive_cli(REGISTRY, _capture_invoke(captured)) result = runner.invoke( - app, ["new", "--name", "w", "--cwd", "/work", "--stage", "all", "--inline", "prompt"] + app, + [ + "new", + "--name", + "w", + "--cwd", + "/work", + "--stage", + "all", + "--inline", + "prompt", + "--workspace", + "auto", + "--workspace-setup", + "skip", + "--worktree", + "create", + "--worktree-path", + "wt", + "--worktree-branch", + "dispatch/lane", + "--worktree-base", + "main", + ], ) assert result.exit_code == 0, result.output assert captured["op"] == "new" @@ -500,6 +589,12 @@ def test_new_stage_and_inline_pass_through() -> None: assert isinstance(params, dict) assert params["stage"] == "all" assert params["inline"] == "prompt" + assert params["workspace"] == "auto" + assert params["workspace_setup"] == "skip" + assert params["worktree"] == "create" + assert str(params["worktree_path"]).endswith("/wt") + assert params["worktree_branch"] == "dispatch/lane" + assert params["worktree_base"] == "main" def test_new_json_output_includes_staged_summary() -> None: @@ -517,6 +612,36 @@ def test_new_json_output_includes_staged_summary() -> None: "session_dir": "/work/.agents/sessions/0AB12x", "files": [], }, + "workspace": { + "mode": "none", + "resolved_mode": "none", + "state": "disabled", + "input_cwd": "/work", + "repo_root": None, + "effective_cwd": "/work", + "environment_file": None, + "environment": None, + "setup": { + "policy": "not_requested", + "ran": False, + "script": None, + "cwd": None, + "exit_code": None, + "duration_ms": None, + "stdout_tail": None, + "stderr_tail": None, + }, + "worktree": { + "mode": "none", + "state": "disabled", + "path": None, + "branch": None, + "base": None, + "head": None, + "source_repo": None, + "created": False, + }, + }, "latest_turn": {"id": None, "status": None, "error": None, "error_at": None}, } diff --git a/tests/surfaces/test_parity.py b/tests/surfaces/test_parity.py index f22f54e..cc03ea7 100644 --- a/tests/surfaces/test_parity.py +++ b/tests/surfaces/test_parity.py @@ -51,6 +51,7 @@ def _stub_invoke(op_id: str, params: dict[str, object]) -> dict[str, object]: "list --unmanaged": "discover", "get": "show", "tail": "transcript", + "history": "history", "watch": "watch", "sync": "sync", "search": "search", diff --git a/tests/test_config.py b/tests/test_config.py index f94ac18..702cc16 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,9 +9,14 @@ def test_runtime_policy_reads_local_config(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("DISPATCH_HOME", str(tmp_path)) - config_path().write_text("[policy]\nallow_attached_writes = true\n") + config_path().write_text( + "[policy]\nallow_attached_writes = true\nallow_workspace_setup = true\n" + "workspace_setup_timeout_seconds = 30\n" + ) assert runtime_policy().allow_attached_writes is True + assert runtime_policy().allow_workspace_setup is True + assert runtime_policy().workspace_setup_timeout_seconds == 30 def test_runtime_policy_env_overrides_local_config( @@ -19,6 +24,12 @@ def test_runtime_policy_env_overrides_local_config( ) -> None: monkeypatch.setenv("DISPATCH_HOME", str(tmp_path)) monkeypatch.setenv("DISPATCH_ALLOW_ATTACHED_WRITES", "0") - config_path().write_text("[policy]\nallow_attached_writes = true\n") - - assert runtime_policy().allow_attached_writes is False + config_path().write_text( + "[policy]\nallow_attached_writes = true\nallow_workspace_setup = true\n" + "workspace_setup_timeout_seconds = 45\n" + ) + + policy = runtime_policy() + assert policy.allow_attached_writes is False + assert policy.allow_workspace_setup is True + assert policy.workspace_setup_timeout_seconds == 45 diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 3ee3dee..3fd180a 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -164,8 +164,8 @@ def test_doctor_warns_for_unversioned_registry_migration( assert registry.status == "warn" assert registry.summary == "registry schema is unversioned" assert registry.detail == ( - "missing tables: lane_model_settings, lane_snapshots, lane_sync_sources, " - "model_catalog, queued_messages" + "missing tables: lane_model_settings, lane_runtime_settings, lane_snapshots, " + "lane_sync_sources, model_catalog, queued_messages" ) assert registry.recovery is not None assert "dispatch down" in registry.recovery diff --git a/uv.lock b/uv.lock index c672815..327213e 100644 --- a/uv.lock +++ b/uv.lock @@ -466,7 +466,7 @@ wheels = [ [[package]] name = "outfitter-dispatch" -version = "0.6.1" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "aiosqlite" },