diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ccf2dd..9887fc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: with: binary_name: nullhub artifact_prefix: nullhub + zig_version: "0.16.0" node_version: "22" node_cache_dependency_path: ui/package-lock.json targets_json: >- @@ -24,7 +25,7 @@ jobs: {"os":"ubuntu-latest","target":"linux-x86_64","zig_target":"x86_64-linux-musl"}, {"os":"ubuntu-latest","target":"linux-aarch64","zig_target":"aarch64-linux-musl"}, {"os":"macos-latest","target":"macos-aarch64","zig_target":"aarch64-macos"}, - {"os":"windows-latest","target":"windows-x86_64","zig_target":"x86_64-windows"} + {"os":"windows-2025-vs2026","target":"windows-x86_64","zig_target":"x86_64-windows"} ] test_command: bash tests/test_backend.sh pre_build_command: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40d3752..290f0d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,7 @@ jobs: with: binary_name: nullhub artifact_prefix: nullhub + zig_version: "0.16.0" node_version: "22" node_cache_dependency_path: ui/package-lock.json targets_json: >- @@ -27,8 +28,8 @@ jobs: {"os":"ubuntu-latest","target":"linux-riscv64","zig_target":"riscv64-linux-musl","zig_cpu":"","ext":""}, {"os":"macos-latest","target":"macos-aarch64","zig_target":"aarch64-macos","zig_cpu":"","ext":""}, {"os":"macos-latest","target":"macos-x86_64","zig_target":"x86_64-macos","zig_cpu":"","ext":""}, - {"os":"windows-latest","target":"windows-x86_64","zig_target":"x86_64-windows","zig_cpu":"","ext":".exe"}, - {"os":"windows-latest","target":"windows-aarch64","zig_target":"aarch64-windows","zig_cpu":"","ext":".exe"} + {"os":"windows-2025-vs2026","target":"windows-x86_64","zig_target":"x86_64-windows","zig_cpu":"","ext":".exe"}, + {"os":"windows-2025-vs2026","target":"windows-aarch64","zig_target":"aarch64-windows","zig_cpu":"","ext":".exe"} ] pre_build_command: | if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then diff --git a/.gitignore b/.gitignore index 5116d31..3f34a07 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ zig-out/ .worktrees/ .generated_ui_assets.zig src/.generated_ui_assets.zig +/.playwright-mcp/ +/nulldesk-web-*.png diff --git a/AGENTS.md b/AGENTS.md index 11894db..e9db1df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,5 @@ # Documentation -Project documentation has moved to [README.md](README.md) and `docs/`. +Project documentation lives in [README.md](README.md) and `docs/`. - Read first: `README.md` -- Design docs: `docs/plans/` diff --git a/CLAUDE.md b/CLAUDE.md index 11894db..e9db1df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,5 @@ # Documentation -Project documentation has moved to [README.md](README.md) and `docs/`. +Project documentation lives in [README.md](README.md) and `docs/`. - Read first: `README.md` -- Design docs: `docs/plans/` diff --git a/HACKATHON_SUBMISSION.md b/HACKATHON_SUBMISSION.md deleted file mode 100644 index 7c64695..0000000 --- a/HACKATHON_SUBMISSION.md +++ /dev/null @@ -1,98 +0,0 @@ -# Agent Flight Recorder - -## Problem Discovered - -NullWatch already provides the observability layer for the nullclaw ecosystem: -run summaries, spans, evals, OTLP ingest, cost, token usage, and failure context. -It also exports a NullHub-compatible manifest. NullHub already provides the -operator UI and orchestration pages, but it did not register NullWatch or expose -its tracing/eval data in the UI. - -## Chosen Solution - -Add a local-first Observability cockpit to NullHub: - -- register `nullwatch` as a known component -- proxy `/api/observability/*` to a managed NullWatch instance -- add a Flight Recorder page for runs, spans, evals, cost, tokens, and errors -- document the local demo flow through NullHub's managed install path - -## Why This Idea Was Chosen - -This is stronger than a single CLI preflight because it connects multiple parts -of the ecosystem into a visible agent platform story: execution, orchestration, -task tracking, observability, and operations. It is still hackathon-sized because -it uses existing NullWatch APIs and NullHub UI patterns instead of changing core -agent runtime behavior. - -## What Was Implemented - -- NullWatch component registration in the NullHub registry. -- Observability reverse proxy with optional bearer token forwarding. -- Sidebar entry and `/observability` UI page. -- API client methods for NullWatch summary, runs, spans, evals, and health. -- README documentation for the proxy and local demo setup. - -## Files Changed - -- `src/installer/registry.zig` -- `src/api/observability.zig` -- `src/api/proxy.zig` -- `src/api/components.zig` -- `src/api/meta.zig` -- `src/root.zig` -- `src/server.zig` -- `ui/src/lib/api/client.ts` -- `ui/src/lib/components/Sidebar.svelte` -- `ui/src/routes/observability/+page.svelte` -- `README.md` -- `HACKATHON_SUBMISSION.md` - -## How To Test Or Demo - -Start NullHub: - -```bash -zig build run -- serve --no-open -``` - -Install NullWatch from NullHub: - -1. Open the web UI. -2. Go to `Install Component`. -3. Select `NullWatch`. -4. Keep or set the API port to `7710`. -5. Finish the wizard. The installer starts the NullWatch instance and NullHub - discovers it automatically. - -Optional sample data can be ingested through the NullHub proxy: - -```bash -curl -X POST http://127.0.0.1:19800/api/observability/v1/spans \ - -H 'Content-Type: application/json' \ - -d '{"run_id":"demo-run-1","trace_id":"trace-demo-1","span_id":"span-1","source":"nullclaw","operation":"tool.call","status":"error","started_at_ms":1710000000000,"ended_at_ms":1710000001500,"tool_name":"shell","error_message":"tool call failed: command timed out","attributes_json":"{\"exit_code\":124}"}' - -curl -X POST http://127.0.0.1:19800/api/observability/v1/evals \ - -H 'Content-Type: application/json' \ - -d '{"run_id":"demo-run-1","eval_key":"tool_success","scorer":"deterministic","score":0.0,"verdict":"fail","dataset":"demo","notes":"The tool call timed out."}' -``` - -Open `/observability` in NullHub and inspect the NullWatch runs. - -## Screenshots - -Flight Recorder overview: - -![NullHub Observability overview](docs/screenshots/nullhub-observability-overview.png) - -Failure detail with tool-call error context: - -![NullHub Observability failure detail](docs/screenshots/nullhub-observability-failure.png) - -## Limitations And Future Improvements - -- `NULLWATCH_URL` remains useful for pointing NullHub at an external NullWatch - instance, but the default demo path uses a managed NullWatch install. -- The first UI version renders a compact timeline, not a full waterfall chart. -- Run correlation with NullBoiler orchestration pages can be added as a follow-up - when both systems share stable run ids. diff --git a/README.md b/README.md index f01ef27..206c5a9 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,16 @@ NullTickets, NullWatch). - **Process supervision** -- start, stop, restart, crash recovery with backoff - **Health monitoring** -- periodic HTTP health checks, dashboard status cards - **Cross-component linking** -- auto-connect `NullTickets -> NullBoiler`, generate native tracker config, and inspect queue/orchestrator status from one UI -- **Config management** -- structured editors for `NullClaw`, `NullBoiler`, `NullTickets`, and `NullWatch`, with raw JSON fallback when needed +- **Config management** -- structured editors for `NullClaw`, `NullBoiler`, `NullTickets`, and `NullWatch`, plus direct raw JSON editing when needed - **Log viewing** -- tail and live SSE streaming per instance - **One-click updates** -- download, migrate config, rollback on failure - **Multi-instance** -- run multiple instances of the same component side by side - **Web UI + CLI** -- browser dashboard for humans, CLI for automation - **Managed instance admin API** -- instance-scoped status, config, models, cron, channels, and skills routes for managed NullClaw installs -- **Orchestration UI** -- workflow editor, poll-based run monitoring, checkpoint forking, encoded workflow/run/store links, and key-value store browser (proxied to NullTickets through NullHub) -- **Observability cockpit** -- local NullWatch run summaries, span timelines, eval results, token usage, cost, and error context through a NullHub proxy +- **NullBoiler UI** -- workflow editor, poll-based run monitoring, checkpoint forking, and encoded workflow/run links +- **NullTickets Store** -- key-value store browser proxied to NullTickets through NullHub +- **NullWatch Flight Recorder** -- local NullWatch run summaries, span timelines, eval results, token usage, cost, and error context through a NullHub proxy +- **Mission Control** -- local-first agent mission replay with workflow execution, role-based agents, failure, checkpoint recovery, durable replay storage, and live telemetry in one screen ## Quick Start @@ -114,17 +116,19 @@ UI modules. NullHub is a generic engine that interprets manifests. **Storage** -- all state lives under `~/.nullhub/` (config, instances, binaries, logs, cached manifests). -**Orchestration proxy** -- requests to `/api/orchestration/*` are reverse-proxied -to the local orchestration stack. Most routes go to NullBoiler's REST API via +**NullBoiler proxy** -- requests to `/api/nullboiler/*` are reverse-proxied +to NullBoiler's REST API via `NULLBOILER_URL` (e.g. `http://localhost:8080`) and optional `NULLBOILER_TOKEN`. -`/api/orchestration/store/*` is proxied to NullTickets via `NULLTICKETS_URL` and + +**NullTickets store proxy** -- requests to `/api/nulltickets/store/*` are +proxied to NullTickets via `NULLTICKETS_URL` and optional `NULLTICKETS_TOKEN`. -**Observability proxy** -- requests to `/api/observability/*` are reverse-proxied +**NullWatch proxy** -- requests to `/api/nullwatch/*` are reverse-proxied to the managed NullWatch instance installed in NullHub. `NULLWATCH_URL` can still override the target for an external NullWatch instance, and `NULLWATCH_TOKEN` overrides the managed instance token when set. The built-in -Observability page uses this proxy to display run summaries, spans, evals, +NullWatch page uses this proxy to display run summaries, spans, evals, latency, cost, and failure context without sending data to hosted services. Local NullWatch setup: @@ -137,29 +141,65 @@ Local NullWatch setup: 2. In the web UI, open **Install Component**, select **NullWatch**, keep or set the API port to `7710`, and finish the wizard. The installer starts the - NullWatch instance and the observability proxy discovers it automatically. - -3. Optional demo data can be ingested through the NullHub proxy: + NullWatch instance and the NullWatch proxy discovers it automatically. + +**Mission Control API** -- requests to `/api/mission-control/*` drive a +deterministic local replay scenario for the `/mission-control` page. It does +not require hosted infrastructure or model secrets, and it hydrates with real +NullBoiler workflow evidence and NullWatch trace detail when matching local +instances are available. Responses include a schema version, scenario id, +deterministic replay mode, controls, graph, timeline, telemetry, NullWatch-style +run/span/eval trace references, and structured conflict errors for invalid +actions. The scenario lives in a versioned embedded replay fixture at +`src/core/mission_control/code_red.v1.json`; `zig build test` validates fixture +schema, references, ordering, required phases, graph links, and telemetry phase +coverage. Mission timeline trace links deep-link to `/nullwatch?run_id=...`. +When a managed NullWatch instance is running, `/mission-control` hydrates the +failure and recovery trace panels from live run detail through the NullWatch +proxy and preserves the selected watch in trace links. When a managed +NullBoiler instance has matching workflow evidence, the Mission Control API +includes that instance name with real workflow run links and checkpoint metadata +resolved through the NullBoiler proxy. +`GET /api/mission-control/replay` exports the current snapshot, source fixture, +the side-by-side failed/recovered replay artifact comparison once the recovered +run completes, and ecosystem mapping metadata as a portable JSON artifact for +debugging and review. `POST /api/mission-control/replay/save` stores that +artifact under `~/.nullhub/mission-control/replays/`; `GET +/api/mission-control/replays` lists saved replay records and `GET +/api/mission-control/replays/{id}` reads the durable artifact back. + +### Mission Control Replay + +Start NullHub locally and open `/mission-control`: - ```bash - curl -X POST http://127.0.0.1:19800/api/observability/v1/spans \ - -H 'Content-Type: application/json' \ - -d '{"run_id":"demo-run-1","trace_id":"trace-demo-1","span_id":"span-1","source":"nullclaw","operation":"tool.call","status":"error","started_at_ms":1710000000000,"ended_at_ms":1710000001500,"tool_name":"shell","error_message":"tool call failed: command timed out","attributes_json":"{\"exit_code\":124}"}' +```bash +zig build run -- serve --host 127.0.0.1 --port 19802 --no-open +``` - curl -X POST http://127.0.0.1:19800/api/observability/v1/evals \ - -H 'Content-Type: application/json' \ - -d '{"run_id":"demo-run-1","eval_key":"tool_success","scorer":"deterministic","score":0.0,"verdict":"fail","dataset":"demo","notes":"The tool call timed out."}' - ``` +The page provides `Replay Mission`, `Reset`, `Launch Mission`, and +`Fork From Checkpoint` controls. `Replay Mission` runs the deterministic reset, +launch, failure hold, checkpoint fork, and recovered replay sequence from one +click. Timeline events include trace chips that map the replay back to local +NullWatch-style run ids, span ids, operations, and eval keys. The page also +includes phase milestones and a failed-vs-recovered replay artifact comparison +panel. -### Observability Screenshots +Export the current replay artifact: -Flight Recorder overview: +```bash +curl -fsS http://127.0.0.1:19802/api/mission-control/replay \ + -o mission-control-replay.json +``` -![NullHub Observability overview](docs/screenshots/nullhub-observability-overview.png) +The same export is available from the `Save Replay` button in Mission Control, +which also writes a durable server-side copy. +See `docs/mission-control.md` for the artifact shape and ecosystem mapping. -Failure detail with tool-call error context: +Run the live API smoke test against a started server: -![NullHub Observability failure detail](docs/screenshots/nullhub-observability-failure.png) +```bash +NULLHUB_URL=http://127.0.0.1:19802 ./tests/test_mission_control_smoke.sh +``` ## Development @@ -182,6 +222,7 @@ End-to-end: ```bash ./tests/test_e2e.sh +NULLHUB_URL=http://127.0.0.1:19802 ./tests/test_mission_control_smoke.sh ``` `zig build test-integration` runs structured backend HTTP integration tests @@ -193,7 +234,7 @@ against a real `nullhub` process started in a temporary home directory. - Svelte 5 + SvelteKit (static adapter) - JSON over HTTP/1.1 - SSE for instance log streaming -- Poll-based orchestration run updates over the `/orchestration/runs/{id}/stream` API +- Poll-based NullBoiler run updates over the `/api/nullboiler/runs/{id}/stream` API ## Project Layout @@ -204,19 +245,30 @@ src/ server.zig # HTTP server (API + static UI) auth.zig # Optional bearer token auth api/ # REST endpoints (components, instances, wizard, ...) - orchestration.zig # Reverse proxy to NullBoiler orchestration API - observability.zig # Reverse proxy to NullWatch tracing/eval API + nullboiler.zig # Reverse proxy to NullBoiler workflow/run API + nulltickets.zig # Reverse proxy to NullTickets store API + nullwatch.zig # Reverse proxy to NullWatch tracing/eval API + mission_control.zig # HTTP adapter for local mission replay commands core/ # Manifest parser, state, platform, paths + mission_control.zig # Local deterministic agent mission domain model + mission_control_replay.zig # Typed replay fixture parser and validator + mission_control/ # Embedded Mission Control replay fixtures installer/ # Download, build, UI module fetching supervisor/ # Process spawn, health checks, manager ui/src/ routes/ # SvelteKit pages - orchestration/ # Orchestration pages (dashboard, workflows, runs, store) - observability/ # NullWatch flight recorder page + nullboiler/ # NullBoiler pages (dashboard, workflows, runs) + nulltickets/ # NullTickets pages (store) + nullwatch/ # NullWatch Flight Recorder page + mission-control/ # Local agent mission control room lib/components/ # Reusable Svelte components - orchestration/ # GraphViewer, StateInspector, RunEventLog, InterruptPanel, + nullboiler/ # GraphViewer, StateInspector, RunEventLog, InterruptPanel, # CheckpointTimeline, WorkflowJsonEditor, NodeCard, SendProgressBar + nulltickets/ # NullTickets store selectors and controls lib/api/ # Typed API client + lib/missionControl/ # Mission Control feature helpers tests/ test_e2e.sh # End-to-end test script +docs/ + mission-control.md # Mission Control replay and artifact contract ``` diff --git a/TESTING.md b/TESTING.md index af08236..3d9cad0 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,7 +8,7 @@ The aim is not a single large testing rewrite. The aim is to improve confidence - make the existing backend test suite a reliable daily gate - expand coverage into the highest-risk backend areas -- add the missing frontend unit-test layer +- expand frontend unit coverage beyond the current targeted UI helper tests - replace shell-only smoke reliance with structured integration coverage - keep browser E2E small and focused - adopt NullClaw-style expectations: every behavior change gets tests, every bug fix gets a regression test @@ -20,7 +20,8 @@ As of the current `main` branch: - NullHub already has substantial Zig unit-test coverage in parts of the backend. - Coverage is concentrated heavily in API and routing code. - The project has a shell smoke script at `tests/test_e2e.sh`. -- The project does not yet have a committed frontend unit-test harness. +- The project has a targeted Mission Control UI helper test; broader component + and route-level frontend coverage is still light. - CI currently runs backend tests, the shell smoke test on Linux, and ReleaseSmall binary builds. This means the main gap is not "no tests". The gap is uneven coverage and missing layers. @@ -49,9 +50,9 @@ The snapshot below is based on the current `src/` tree and the committed test di | Config, state, and paths | Medium | `src/core/state.zig`, `src/api/config.zig`, `src/core/paths.zig` | add tests around persisted-state restoration and migration-sensitive behavior | | Auth and access control | Light | `src/auth.zig`, `src/access.zig` | add unauthorized origin, token failure, and sensitive-route boundary tests | | Service install/uninstall/status | Light | `src/service.zig` | add stronger platform-specific generation and failure-path tests | -| Orchestration proxy | Light | `src/api/orchestration.zig` | add upstream error mapping, token/header forwarding, and store-vs-boiler routing tests | +| Product proxies | Light | `src/api/nullboiler.zig`, `src/api/nulltickets.zig`, `src/api/nullwatch.zig` | add upstream error mapping and token/header forwarding tests | | Discovery, mDNS, and compat layers | Light | `src/discovery.zig`, `src/mdns.zig`, `src/compat/*` | add degraded-mode and missing-tool fallback coverage | -| Frontend UI logic | Missing | no committed UI test harness in `ui/` | add Vitest and Testing Library first | +| Frontend UI logic | Light | `ui/src/lib/missionControl/replayAutomation.test.mjs` covers the replay automation helper | add broader component and route-level coverage | | Structured backend integration tests | Light | shell smoke only in `tests/test_e2e.sh` | add a real HTTP/integration harness with fixtures | | Browser end-to-end | Missing | no Playwright or equivalent suite | add a very small critical-flow suite after UI unit tests land | @@ -108,7 +109,7 @@ Use for: - HTTP route behavior across modules - boot and runtime lifecycle flows - managed-instance interactions -- orchestration proxy behavior with fake upstreams +- product proxy behavior with fake upstreams - installer and update scenarios using fixtures These should not require a browser. @@ -120,7 +121,7 @@ Use for: - API client helpers - stores and route transforms - form validation and state behavior -- orchestration helpers and key UI components +- NullBoiler helper and key UI components Recommended tooling: @@ -226,7 +227,7 @@ Dependencies: Purpose: -- make installer, supervisor, and orchestration tests cheaper to write +- make installer, supervisor, and product proxy tests cheaper to write Suggested PR: @@ -243,7 +244,7 @@ Target order: 1. supervisor and process lifecycle 2. installer and updates 3. auth and access control -4. orchestration proxy behavior +4. product proxy behavior 5. service generation and status behavior 6. discovery and degraded-mode fallbacks @@ -252,7 +253,7 @@ Example PRs: - `test(supervisor): cover restart threshold and crash recovery transitions` - `test(installer): cover rollback and duplicate-instance failure paths` - `test(auth): cover unauthorized origin and bearer-token failure paths` -- `test(orchestration): cover upstream error mapping and token forwarding` +- `test(product-proxies): cover upstream error mapping and token forwarding` - `test(service): cover launchd/systemd generation and failure paths` Dependencies: @@ -269,23 +270,23 @@ Suggested PRs: - `test(integration): add structured HTTP smoke harness` - `test(integration): cover instance lifecycle and config mutation flows` -- `test(integration): cover orchestration proxy scenarios` +- `test(integration): cover product proxy scenarios` Dependencies: - Phase 4 strongly recommended -### Phase 7: Frontend Unit-Test Harness +### Phase 7: Frontend Unit Coverage Purpose: -- add the missing UI logic test layer +- expand the UI logic test layer Suggested PRs: -- `test(ui): add Vitest and Testing Library harness` +- `test(ui): add component-level Svelte test coverage` - `test(ui): cover API client and config-form helpers` -- `test(ui): cover orchestration helpers and key components` +- `test(ui): cover NullBoiler helpers and key components` Dependencies: @@ -358,7 +359,7 @@ zig build test -Dembed-ui=false -Dbuild-ui=false --summary all bash tests/test_e2e.sh ``` -Future UI test changes after the harness exists: +Frontend logic changes: ```bash npm --prefix ui test -- --run diff --git a/docs/mission-control.md b/docs/mission-control.md new file mode 100644 index 0000000..d3c1540 --- /dev/null +++ b/docs/mission-control.md @@ -0,0 +1,105 @@ +# Mission Control + +Mission Control is a local replay and recovery workspace for NullHub. It keeps +a deterministic mission scenario available without requiring hosted services, +then hydrates that scenario with live NullWatch and NullBoiler evidence when +matching local instances are running. + +The built-in scenario fixture is intentionally versioned and checked by tests. +It gives `/mission-control` a stable baseline state while still allowing live +trace and workflow panels to replace fixture-only evidence when real local +services are available. + +## Replay API + +Mission Control exposes the current replay as JSON: + +```text +GET /api/mission-control/replay +``` + +It can also persist the current replay artifact in NullHub storage: + +```text +POST /api/mission-control/replay/save +GET /api/mission-control/replays +GET /api/mission-control/replays/{id} +``` + +Replay export does not mutate NullTickets, NullBoiler, NullClaw, or NullWatch. +When matching local NullBoiler evidence is available, the artifact includes real +workflow run ids and checkpoint metadata. When a local NullWatch instance is +running, the UI hydrates failed and recovered trace panels before export/save. +Saved artifacts are written to `~/.nullhub/mission-control/replays/` as +self-contained JSON files so they survive process restarts. + +## Shape + +The exported JSON contains: + +- `artifact_schema_version` - version of the export wrapper. +- `artifact_kind` - `nullhub.mission_control.replay`. +- `generated_at_ms` - export timestamp. +- `replay_fixture_path` - repository path of the embedded scenario fixture. +- `scenario_id`, `scenario_version`, `mode` - replay identity. +- `snapshot` - the current rendered Mission Control state. + - `replay_comparison` - side-by-side failed and recovered run replay + artifacts with verdicts, telemetry, trace ids, workflow ids, checkpoint + linkage, and deltas once the recovered run completes; it is `null` before + the recovered artifact exists in the current state. +- `replay_fixture` - the source fixture used to derive the replay. +- `workflow_evidence` - resolved NullBoiler run/checkpoint evidence when a + matching local instance is available. +- `ecosystem_mapping` - how the fixture maps to nullclaw ecosystem concepts. + +## Ecosystem Mapping + +`ecosystem_mapping.nulltickets` points to tracker-style evidence: + +- `events[source=nulltickets]` +- `graph.nodes[kind=tracker]` + +`ecosystem_mapping.nullboiler` points to NullBoiler workflow evidence: + +- phase timing and workflow graph edges +- `workflow_evidence` +- `checkpoint_id` +- failed and recovered run ids +- human fork instruction + +`ecosystem_mapping.nullclaw` points to agent evidence: + +- role-based agents +- agent graph nodes +- NullClaw-style event source entries + +`ecosystem_mapping.nullwatch` points to NullWatch trace evidence: + +- failed and recovered run ids +- `events[].trace` +- telemetry counters +- failure and recovery run panels + +## Local Export + +Start NullHub: + +```bash +zig build run -- serve --host 127.0.0.1 --port 19802 --no-open +``` + +Export the current replay: + +```bash +curl -fsS http://127.0.0.1:19802/api/mission-control/replay \ + -o mission-control-replay.json +``` + +Or use the UI button: + +```text +/mission-control -> Save Replay +``` + +The exported JSON is a compact local record of the replay state at the moment +it was captured. diff --git a/docs/screenshots/nullhub-observability-failure.png b/docs/screenshots/nullhub-observability-failure.png deleted file mode 100644 index 19f38d4..0000000 Binary files a/docs/screenshots/nullhub-observability-failure.png and /dev/null differ diff --git a/docs/screenshots/nullhub-observability-overview.png b/docs/screenshots/nullhub-observability-overview.png deleted file mode 100644 index 31058e0..0000000 Binary files a/docs/screenshots/nullhub-observability-overview.png and /dev/null differ diff --git a/docs/superpowers/plans/2026-03-18-report-command.md b/docs/superpowers/plans/2026-03-18-report-command.md index 8345938..cb7f712 100644 --- a/docs/superpowers/plans/2026-03-18-report-command.md +++ b/docs/superpowers/plans/2026-03-18-report-command.md @@ -145,7 +145,7 @@ pub const ReportOptions = struct { - [ ] **Step 4: Add `report` to Command union** -Add `report: ReportOptions,` after `add_source: AddSourceOptions,` in the `Command` union. +Add `report: ReportOptions,` to the `Command` union near the other CLI commands. - [ ] **Step 5: Add parseReport function and wire into parse()** @@ -1345,7 +1345,7 @@ git commit -m "Add report API endpoints for preview and submit" - [ ] **Step 1: Add API methods to client.ts** -Add before the `...createOrchestrationApi(request, withQuery)` line: +Add before the `...createNullBoilerApi(request, withQuery)` line: ```typescript reportPreview: (data: { repo: string; type: string; message: string }) => diff --git a/src/api/components.zig b/src/api/components.zig index 7998c86..64580dc 100644 --- a/src/api/components.zig +++ b/src/api/components.zig @@ -62,7 +62,7 @@ fn buildListJson(allocator: std.mem.Allocator, s: *state_mod.State) ![]const u8 // Count managed instances from state const instance_count = countInstancesFromState(s, comp.name); - // standalone = has dot-dir config but not yet imported into nullhub + // standalone = dot-dir config exists without a managed NullHub instance const has_dot_dir = hasStandaloneInstall(allocator, comp.name); const standalone = has_dot_dir and instance_count == 0; const installed = has_dot_dir or instance_count > 0; @@ -130,10 +130,20 @@ pub fn handleManifest(allocator: std.mem.Allocator, component_name: []const u8) return null; } -/// Handle POST /api/components/refresh — placeholder for future manifest refresh. -/// Returns a success response body. +/// Handle POST /api/components/refresh — report the current known registry snapshot. pub fn handleRefresh(allocator: std.mem.Allocator) ![]const u8 { - return try allocator.dupe(u8, "{\"status\":\"ok\"}"); + var installable_count: usize = 0; + var alpha_count: usize = 0; + for (registry.known_components) |comp| { + if (comp.installable) installable_count += 1; + if (comp.is_alpha) alpha_count += 1; + } + + return try std.fmt.allocPrint( + allocator, + "{{\"status\":\"ok\",\"component_count\":{d},\"installable_count\":{d},\"alpha_count\":{d}}}", + .{ registry.known_components.len, installable_count, alpha_count }, + ); } // ─── Route extraction helper ───────────────────────────────────────────────── @@ -215,7 +225,7 @@ test "handleList returns valid JSON with all known components" { try std.testing.expect(std.mem.indexOf(u8, json, "Autonomous AI agent runtime") != null); try std.testing.expect(std.mem.indexOf(u8, json, "DAG-based workflow orchestrator") != null); try std.testing.expect(std.mem.indexOf(u8, json, "Task and issue tracker") != null); - try std.testing.expect(std.mem.indexOf(u8, json, "Headless observability") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "Headless tracing") != null); // Verify repo fields try std.testing.expect(std.mem.indexOf(u8, json, "\"nullclaw/nullclaw\"") != null); @@ -253,7 +263,9 @@ test "handleRefresh returns ok status" { const json = try handleRefresh(allocator); defer allocator.free(json); - try std.testing.expectEqualStrings("{\"status\":\"ok\"}", json); + try std.testing.expect(std.mem.indexOf(u8, json, "\"status\":\"ok\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"component_count\":4") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"installable_count\":4") != null); } test "extractComponentName parses paths correctly" { diff --git a/src/api/instances.zig b/src/api/instances.zig index 1b61e59..7c2547a 100644 --- a/src/api/instances.zig +++ b/src/api/instances.zig @@ -16,6 +16,7 @@ const managed_skills = @import("../managed_skills.zig"); const manifest_mod = @import("../core/manifest.zig"); const managed_cli = @import("managed_cli.zig"); const nullclaw_web_channel = @import("../core/nullclaw_web_channel.zig"); +const nullclaw_gateway_config = @import("../core/nullclaw_gateway_config.zig"); const query_api = @import("query.zig"); const test_helpers = @import("../test_helpers.zig"); const instance_runtime = @import("instance_runtime.zig"); @@ -34,11 +35,6 @@ fn defaultLaunchModeForComponent(component: []const u8) []const u8 { return "gateway"; } -fn isLegacyDefaultLaunchMode(component: []const u8, launch_mode: []const u8) bool { - const default_launch = defaultLaunchModeForComponent(component); - return !std.mem.eql(u8, default_launch, "gateway") and std.mem.eql(u8, launch_mode, "gateway"); -} - const StartBinary = struct { path: []const u8, version: []const u8, @@ -482,24 +478,6 @@ const NullclawOnboardingStatus = struct { } }; -const NullclawBootstrapMemoryProbe = struct { - exists: bool = false, - timestamp: ?[]u8 = null, - - fn deinit(self: *NullclawBootstrapMemoryProbe, allocator: std.mem.Allocator) void { - if (self.timestamp) |value| allocator.free(value); - self.* = .{}; - } - - fn takeTimestamp(self: *NullclawBootstrapMemoryProbe) ?[]u8 { - const value = self.timestamp; - self.timestamp = null; - return value; - } -}; - -const nullclaw_bootstrap_memory_key = "__bootstrap.prompt.BOOTSTRAP.md"; - fn fileExistsAbsolute(path: []const u8) bool { std_compat.fs.accessAbsolute(path, .{}) catch return false; return true; @@ -509,46 +487,8 @@ fn nullclawWorkspaceStatePath(allocator: std.mem.Allocator, workspace_dir: []con return std.fs.path.join(allocator, &.{ workspace_dir, ".nullclaw", "workspace-state.json" }); } -fn probeNullclawBootstrapInMemory( - allocator: std.mem.Allocator, - s: *state_mod.State, - paths: paths_mod.Paths, - component: []const u8, - name: []const u8, -) NullclawBootstrapMemoryProbe { - const args = [_][]const u8{ - "memory", - "get", - nullclaw_bootstrap_memory_key, - "--json", - }; - const stdout = managed_cli.tryRunJsonSuccess(allocator, s, paths, component, name, &args) orelse return .{}; - defer allocator.free(stdout); - - const parsed = std.json.parseFromSlice(std.json.Value, allocator, stdout, .{ - .allocate = .alloc_if_needed, - .ignore_unknown_fields = true, - }) catch return .{}; - defer parsed.deinit(); - - switch (parsed.value) { - .null => return .{}, - .object => |obj| { - var probe = NullclawBootstrapMemoryProbe{ .exists = true }; - if (obj.get("timestamp")) |timestamp_value| { - if (timestamp_value == .string and timestamp_value.string.len > 0) { - probe.timestamp = allocator.dupe(u8, timestamp_value.string) catch null; - } - } - return probe; - }, - else => return .{}, - } -} - fn readNullclawOnboardingStatus( allocator: std.mem.Allocator, - s: *state_mod.State, paths: paths_mod.Paths, component: []const u8, name: []const u8, @@ -602,17 +542,7 @@ fn readNullclawOnboardingStatus( } status.completed = status.onboarding_completed_at != null and !status.bootstrap_exists; - - var bootstrap_probe = NullclawBootstrapMemoryProbe{}; - if (!status.completed and !status.bootstrap_exists) { - bootstrap_probe = probeNullclawBootstrapInMemory(allocator, s, paths, component, name); - if (status.bootstrap_seeded_at == null) { - status.bootstrap_seeded_at = bootstrap_probe.takeTimestamp(); - } - } - defer bootstrap_probe.deinit(allocator); - - status.pending = !status.completed and (status.bootstrap_exists or status.bootstrap_seeded_at != null or bootstrap_probe.exists); + status.pending = !status.completed and (status.bootstrap_exists or status.bootstrap_seeded_at != null); return status; } @@ -884,6 +814,196 @@ fn writeJsonConfigValue(allocator: std.mem.Allocator, config_path: []const u8, v try out.writeAll("\n"); } +pub const GatewayProxyUpstream = struct { + port: u16, + token: []u8, + upstream_path: []const u8, + body: []const u8, + body_owned: bool = false, + event_stream: bool, + + pub fn deinit(self: GatewayProxyUpstream, allocator: std.mem.Allocator) void { + allocator.free(self.token); + if (self.body_owned) allocator.free(self.body); + } +}; + +pub const GatewayProxyPrepareResult = union(enum) { + no_match, + response: ApiResponse, + upstream: GatewayProxyUpstream, +}; + +fn resolveGatewayPort( + allocator: std.mem.Allocator, + paths: paths_mod.Paths, + manager: *manager_mod.Manager, + component: []const u8, + name: []const u8, + entry: state_mod.InstanceEntry, +) ?u16 { + const snapshot = instance_runtime.resolve(allocator, paths, manager, component, name, entry); + if (snapshot.port != 0) return snapshot.port; + return instance_runtime.readPortFromConfig(allocator, paths, component, name, "gateway.port"); +} + +fn gatewayNotReady() ApiResponse { + return .{ + .status = "503 Service Unavailable", + .content_type = "application/json", + .body = "{\"error\":\"nullclaw gateway is not running\"}", + }; +} + +fn gatewayNotPrepared() ApiResponse { + return .{ + .status = "409 Conflict", + .content_type = "application/json", + .body = "{\"error\":\"nullclaw gateway pairing is not configured\"}", + }; +} + +const GatewayProxyRoute = struct { + upstream_path: []const u8, + event_stream: bool, + body_mode: enum { raw, agent_stream_a2a }, +}; + +fn gatewayProxyRouteForAction(action: []const u8) ?GatewayProxyRoute { + if (std.mem.eql(u8, action, "agent-stream")) return .{ .upstream_path = "/a2a", .event_stream = true, .body_mode = .agent_stream_a2a }; + if (std.mem.eql(u8, action, "a2a")) return .{ .upstream_path = "/a2a", .event_stream = false, .body_mode = .raw }; + if (std.mem.eql(u8, action, "a2a-stream")) return .{ .upstream_path = "/a2a", .event_stream = true, .body_mode = .raw }; + if (std.mem.eql(u8, action, "transcribe")) return .{ .upstream_path = "/media/transcribe", .event_stream = false, .body_mode = .raw }; + return null; +} + +fn buildAgentStreamA2aBody(allocator: std.mem.Allocator, body: []const u8) ![]u8 { + const parsed = std.json.parseFromSlice(struct { + message: ?[]const u8 = null, + session_key: ?[]const u8 = null, + context_id: ?[]const u8 = null, + request_id: ?[]const u8 = null, + message_id: ?[]const u8 = null, + }, allocator, body, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch return error.InvalidJson; + defer parsed.deinit(); + + const message = parsed.value.message orelse return error.MissingMessage; + if (message.len == 0) return error.MissingMessage; + + const now = std_compat.time.milliTimestamp(); + const request_id = parsed.value.request_id orelse ""; + const message_id = parsed.value.message_id orelse ""; + const context_id = parsed.value.context_id orelse (parsed.value.session_key orelse ""); + + var buf = std.array_list.Managed(u8).init(allocator); + errdefer buf.deinit(); + + try buf.appendSlice("{\"jsonrpc\":\"2.0\",\"id\":\""); + if (request_id.len > 0) { + try appendEscaped(&buf, request_id); + } else { + const generated = try std.fmt.allocPrint(allocator, "nullhub-agent-stream-{d}", .{now}); + defer allocator.free(generated); + try buf.appendSlice(generated); + } + try buf.appendSlice("\",\"method\":\"message/stream\",\"params\":{\"message\":{\"kind\":\"message\",\"role\":\"user\",\"messageId\":\""); + if (message_id.len > 0) { + try appendEscaped(&buf, message_id); + } else { + const generated = try std.fmt.allocPrint(allocator, "msg-nullhub-{d}", .{now}); + defer allocator.free(generated); + try buf.appendSlice(generated); + } + try buf.appendSlice("\""); + if (context_id.len > 0) { + try buf.appendSlice(",\"contextId\":\""); + try appendEscaped(&buf, context_id); + try buf.appendSlice("\""); + } + try buf.appendSlice(",\"parts\":[{\"kind\":\"text\",\"text\":\""); + try appendEscaped(&buf, message); + try buf.appendSlice("\"}]},\"configuration\":{\"acceptedOutputModes\":[\"text/plain\"]}}}"); + + return try buf.toOwnedSlice(); +} + +pub fn isGatewayProxyPath(target: []const u8) bool { + const parsed = parsePath(target) orelse return false; + const action = parsed.action orelse return false; + return gatewayProxyRouteForAction(action) != null; +} + +pub fn prepareGatewayProxy( + allocator: std.mem.Allocator, + s: *state_mod.State, + manager: *manager_mod.Manager, + paths: paths_mod.Paths, + method: []const u8, + target: []const u8, + body: []const u8, +) GatewayProxyPrepareResult { + const parsed = parsePath(target) orelse return .no_match; + const action = parsed.action orelse return .no_match; + const route = gatewayProxyRouteForAction(action) orelse return .no_match; + if (!std.mem.eql(u8, method, "POST")) return .{ .response = methodNotAllowed() }; + if (!std.mem.eql(u8, parsed.component, "nullclaw")) { + return .{ .response = badRequest("{\"error\":\"gateway proxy routes are only supported for nullclaw instances\"}") }; + } + + const proxy_body = switch (route.body_mode) { + .raw => body, + .agent_stream_a2a => buildAgentStreamA2aBody(allocator, body) catch |err| switch (err) { + error.InvalidJson => return .{ .response = badRequest("{\"error\":\"invalid JSON body\"}") }, + error.MissingMessage => return .{ .response = badRequest("{\"error\":\"message is required\"}") }, + else => return .{ .response = helpers.serverError() }, + }, + }; + const proxy_body_owned = route.body_mode == .agent_stream_a2a; + var proxy_body_transferred = false; + defer if (proxy_body_owned and !proxy_body_transferred) allocator.free(proxy_body); + + const entry = s.getInstance(parsed.component, parsed.name) orelse return .{ .response = notFound() }; + var access = nullclaw_gateway_config.loadAccess(allocator, paths, parsed.component, parsed.name) catch |err| switch (err) { + error.UnsupportedComponent => return .{ .response = badRequest("{\"error\":\"unsupported component\"}") }, + error.FileNotFound, + error.GatewayConfigMissing, + error.GatewayTokenMissing, + error.GatewayPairingMissing, + => return .{ .response = gatewayNotPrepared() }, + else => return .{ .response = helpers.serverError() }, + }; + errdefer access.deinit(allocator); + + const snapshot = instance_runtime.resolve(allocator, paths, manager, parsed.component, parsed.name, entry); + if (snapshot.status != .running) { + access.deinit(allocator); + return .{ .response = gatewayNotReady() }; + } + + const port = resolveGatewayPort(allocator, paths, manager, parsed.component, parsed.name, entry) orelse { + access.deinit(allocator); + return .{ .response = .{ .status = "503 Service Unavailable", .content_type = "application/json", .body = "{\"error\":\"gateway port unavailable\"}" } }; + }; + + const token = access.token orelse { + access.deinit(allocator); + return .{ .response = gatewayNotPrepared() }; + }; + access.token = null; + proxy_body_transferred = true; + return .{ .upstream = .{ + .port = port, + .token = token, + .upstream_path = route.upstream_path, + .body = proxy_body, + .body_owned = proxy_body_owned, + .event_stream = route.event_stream, + } }; +} + const ProviderHealthConfig = struct { agents: ?struct { defaults: ?struct { @@ -1219,7 +1339,6 @@ pub const UsageAggregate = struct { }; pub const TOKEN_USAGE_LEDGER_FILENAME = "llm_token_usage.jsonl"; -pub const LEGACY_USAGE_LEDGER_FILENAME = "llm_usage.jsonl"; pub const USAGE_CACHE_VERSION: u32 = 1; pub const USAGE_CACHE_MAX_LEDGER_BYTES: usize = 128 * 1024 * 1024; pub const USAGE_HOURLY_RETENTION_SECS: i64 = 14 * 24 * 60 * 60; @@ -1274,16 +1393,7 @@ pub fn isShortUsageWindow(window: []const u8) bool { } pub fn resolveUsageLedgerPath(allocator: std.mem.Allocator, inst_dir: []const u8) ![]u8 { - const preferred = try std.fs.path.join(allocator, &.{ inst_dir, TOKEN_USAGE_LEDGER_FILENAME }); - std_compat.fs.accessAbsolute(preferred, .{}) catch { - const legacy = try std.fs.path.join(allocator, &.{ inst_dir, LEGACY_USAGE_LEDGER_FILENAME }); - if (std_compat.fs.accessAbsolute(legacy, .{})) |_| { - allocator.free(preferred); - return legacy; - } else |_| {} - allocator.free(legacy); - }; - return preferred; + return std.fs.path.join(allocator, &.{ inst_dir, TOKEN_USAGE_LEDGER_FILENAME }); } pub fn usageCachePath(allocator: std.mem.Allocator, paths: paths_mod.Paths, component: []const u8, name: []const u8) ![]u8 { @@ -2330,8 +2440,8 @@ pub fn handleStart(allocator: std.mem.Allocator, s: *state_mod.State, manager: * const bin_path = start_binary.path; const current_version = start_binary.version; - // Read manifest from binary to get health endpoint and port, falling back - // to registry/config defaults for older binaries without manifest support. + // Read manifest from binary to get health endpoint and port. Registry/config + // defaults remain the deterministic path when manifest probing is unavailable. const known_component = registry.findKnownComponent(component); var health_endpoint: []const u8 = if (known_component) |known| known.default_health_endpoint else "/health"; var port: u16 = if (known_component) |known| known.default_port else 0; @@ -2362,10 +2472,9 @@ pub fn handleStart(allocator: std.mem.Allocator, s: *state_mod.State, manager: * if (!launch_mode_overridden) { if (manifest_launch_mode) |mode| { const should_normalize_launch = - (std.mem.eql(u8, launch_cmd, manifest_launch_command) and !std.mem.eql(u8, launch_cmd, mode)) or - isLegacyDefaultLaunchMode(component, launch_cmd); + std.mem.eql(u8, launch_cmd, manifest_launch_command) and !std.mem.eql(u8, launch_cmd, mode); if (should_normalize_launch) { - launch_cmd = registry.normalizeLaunchCommand(component, mode); + launch_cmd = mode; _ = s.updateInstance(component, name, .{ .version = current_version, .auto_start = entry.auto_start, @@ -2391,8 +2500,7 @@ pub fn handleStart(allocator: std.mem.Allocator, s: *state_mod.State, manager: * } } - const normalized_launch_cmd = registry.normalizeLaunchCommand(component, launch_cmd); - var launch = launch_args_mod.resolve(allocator, normalized_launch_cmd, launch_verbose) catch return badRequest("{\"error\":\"invalid launch_mode\"}"); + var launch = launch_args_mod.resolve(allocator, launch_cmd, launch_verbose) catch return badRequest("{\"error\":\"invalid launch_mode\"}"); defer launch.deinit(); // The launch-mode helper decides whether this mode should be supervised via // an HTTP health endpoint or process liveness only. @@ -2795,7 +2903,7 @@ pub fn handleOnboarding( ) ApiResponse { if (s.getInstance(component, name) == null) return notFound(); - var status = readNullclawOnboardingStatus(allocator, s, paths, component, name) catch + var status = readNullclawOnboardingStatus(allocator, paths, component, name) catch return helpers.serverError(); defer status.deinit(allocator); @@ -3997,7 +4105,7 @@ pub fn handlePatch(s: *state_mod.State, component: []const u8, name: []const u8, defer parsed.deinit(); const new_auto_start = parsed.value.auto_start orelse entry.auto_start; - const new_launch_mode = registry.normalizeLaunchCommand(component, parsed.value.launch_mode orelse entry.launch_mode); + const new_launch_mode = parsed.value.launch_mode orelse entry.launch_mode; const new_verbose = parsed.value.verbose orelse entry.verbose; var validated_launch = launch_args_mod.resolve(s.allocator, new_launch_mode, new_verbose) catch @@ -4665,14 +4773,6 @@ pub fn dispatch( if (!std.mem.eql(u8, method, "POST")) return methodNotAllowed(); return handleAgentInvoke(allocator, s, paths, parsed.component, parsed.name, body); } - if (std.mem.eql(u8, action, "agent-stream")) { - if (!std.mem.eql(u8, method, "POST")) return methodNotAllowed(); - return .{ - .status = "501 Not Implemented", - .content_type = "application/json", - .body = "{\"error\":\"streaming agent sessions are not supported; use POST /agent\"}", - }; - } if (std.mem.eql(u8, action, "agent-sessions")) { return handleAgentSessions(allocator, s, paths, parsed.component, parsed.name, method, target); } @@ -4760,9 +4860,6 @@ test "component default launch mode uses registry metadata" { try std.testing.expectEqualStrings("gateway", defaultLaunchModeForComponent("nullclaw")); try std.testing.expectEqualStrings("serve", defaultLaunchModeForComponent("nullwatch")); try std.testing.expectEqualStrings("gateway", defaultLaunchModeForComponent("unknown-component")); - try std.testing.expect(isLegacyDefaultLaunchMode("nullwatch", "gateway")); - try std.testing.expect(!isLegacyDefaultLaunchMode("nullwatch", "serve")); - try std.testing.expect(!isLegacyDefaultLaunchMode("nullclaw", "gateway")); } test "pipeline summaries accept wrapped lists and JSON string definitions" { @@ -4823,6 +4920,86 @@ fn writeTestInstanceConfig( try file.writeAll("\n"); } +test "nullclaw gateway config patches generic gateway capabilities" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + + try writeTestInstanceConfig( + allocator, + fixture.paths, + "nullclaw", + "hat", + "{\"gateway\":{\"port\":43123,\"max_body_size_bytes\":1024},\"a2a\":{\"enabled\":false},\"memory\":{\"profile\":\"minimal_none\",\"backend\":\"none\",\"auto_save\":false}}", + ); + + var access = try nullclaw_gateway_config.ensureConfig(allocator, fixture.paths, "nullclaw", "hat", .{}); + defer access.deinit(allocator); + const token = access.token.?; + try std.testing.expect(std.mem.startsWith(u8, token, nullclaw_gateway_config.token_prefix)); + try std.testing.expect(access.changed); + + const config_path = try fixture.paths.instanceConfig(allocator, "nullclaw", "hat"); + defer allocator.free(config_path); + const file = try std_compat.fs.openFileAbsolute(config_path, .{}); + defer file.close(); + const bytes = try file.readToEndAlloc(allocator, 1024 * 1024); + defer allocator.free(bytes); + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, bytes, .{ .allocate = .alloc_always }); + defer parsed.deinit(); + + const gateway = parsed.value.object.get("gateway").?.object; + const a2a = parsed.value.object.get("a2a").?.object; + try std.testing.expect(gateway.get("require_pairing").?.bool); + try std.testing.expect(gateway.get("max_body_size_bytes").?.integer >= nullclaw_gateway_config.min_body_size); + try std.testing.expect(gateway.get("request_timeout_secs").?.integer >= nullclaw_gateway_config.min_timeout_secs); + try std.testing.expect(a2a.get("enabled").?.bool); + try std.testing.expect(a2a.get("multi_modal").?.bool); + + const expected_hash = try nullclaw_gateway_config.hashGatewayTokenAlloc(allocator, token); + defer allocator.free(expected_hash); + const paired_tokens = gateway.get("paired_tokens").?.array.items; + try std.testing.expectEqual(@as(usize, 1), paired_tokens.len); + try std.testing.expectEqualStrings(expected_hash, paired_tokens[0].string); + try std.testing.expect(!nullclaw_gateway_config.isNullhubGatewayToken(paired_tokens[0].string)); + + const token_path = try nullclaw_gateway_config.gatewayTokenPath(allocator, fixture.paths, "nullclaw", "hat"); + defer allocator.free(token_path); + const token_file = try std_compat.fs.openFileAbsolute(token_path, .{}); + defer token_file.close(); + const stored_token_bytes = try token_file.readToEndAlloc(allocator, 16 * 1024); + defer allocator.free(stored_token_bytes); + try std.testing.expectEqualStrings(token, std.mem.trim(u8, stored_token_bytes, " \t\r\n")); + + var access2 = try nullclaw_gateway_config.ensureConfig(allocator, fixture.paths, "nullclaw", "hat", .{}); + defer access2.deinit(allocator); + try std.testing.expectEqualStrings(token, access2.token.?); + try std.testing.expect(!access2.changed); + + var loaded = try nullclaw_gateway_config.loadAccess(allocator, fixture.paths, "nullclaw", "hat"); + defer loaded.deinit(allocator); + try std.testing.expectEqualStrings(token, loaded.token.?); +} + +test "buildAgentStreamA2aBody translates managed agent request to A2A message stream" { + const allocator = std.testing.allocator; + const body = try buildAgentStreamA2aBody( + allocator, + "{\"message\":\"hello \\\"world\\\"\",\"session_key\":\"interview-1\",\"request_id\":\"req-1\",\"message_id\":\"msg-1\",\"provider\":\"ignored\"}", + ); + defer allocator.free(body); + + try std.testing.expect(std.mem.indexOf(u8, body, "\"method\":\"message/stream\"") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"id\":\"req-1\"") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"messageId\":\"msg-1\"") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"contextId\":\"interview-1\"") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "\"text\":\"hello \\\\\"world\\\\\"\"") != null); + try std.testing.expect(isGatewayProxyPath("/api/instances/nullclaw/hat/agent-stream")); + try std.testing.expect(isGatewayProxyPath("/api/instances/nullclaw/hat/a2a")); + try std.testing.expect(isGatewayProxyPath("/api/instances/nullclaw/hat/a2a-stream")); + try std.testing.expect(isGatewayProxyPath("/api/instances/nullclaw/hat/transcribe")); +} + fn writeTestTrackerWorkflow( allocator: std.mem.Allocator, paths: paths_mod.Paths, @@ -5366,7 +5543,7 @@ test "handleStart normalizes manifest binary command to runnable launch args" { mctx.manager.stopInstance("nullwatch", "watch") catch {}; } -test "handleStart normalizes legacy default launch mode for nullwatch" { +test "handleStart preserves explicit launch mode when it differs from manifest mode" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest; const allocator = std.testing.allocator; @@ -5400,9 +5577,9 @@ test "handleStart normalizes legacy default launch mode for nullwatch" { try std.testing.expectEqualStrings("200 OK", resp.status); const entry = s.getInstance("nullwatch", "watch").?; - try std.testing.expectEqualStrings("serve", entry.launch_mode); + try std.testing.expectEqualStrings("gateway", entry.launch_mode); const inst = mctx.manager.instances.get("nullwatch/watch").?; - try std.testing.expectEqualStrings("serve", inst.launch_args[0]); + try std.testing.expectEqualStrings("gateway", inst.launch_args[0]); mctx.manager.stopInstance("nullwatch", "watch") catch {}; } @@ -6172,51 +6349,7 @@ test "handleOnboarding reports pending bootstrap from workspace state without di try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"bootstrap_seeded_at\":\"2026-03-13T01:17:17Z\"") != null); } -test "handleOnboarding falls back to CLI bootstrap memory for legacy sqlite workspace" { - const allocator = std.testing.allocator; - var state_fixture = try test_helpers.TempPaths.init(allocator); - defer state_fixture.deinit(); - const state_path = try state_fixture.paths.state(allocator); - defer allocator.free(state_path); - var s = state_mod.State.init(allocator, state_path); - defer s.deinit(); - var mctx = TestManagerCtx.init(allocator); - defer mctx.deinit(allocator); - - try s.addInstance("nullclaw", "legacy-agent", .{ .version = "1.0.3" }); - const script = - \\#!/bin/sh - \\if [ "$1" = "memory" ] && [ "$2" = "get" ] && [ "$3" = "__bootstrap.prompt.BOOTSTRAP.md" ]; then - \\ if [ -z "$NULLCLAW_HOME" ]; then - \\ echo "missing home" >&2 - \\ exit 1 - \\ fi - \\ printf '%s\n' '{"key":"__bootstrap.prompt.BOOTSTRAP.md","category":"core","timestamp":"2026-03-13T02:37:27Z","content":"# bootstrap","session_id":null}' - \\ exit 0 - \\fi - \\echo "unexpected args" >&2 - \\exit 1 - \\ - ; - try writeTestBinary(allocator, mctx.paths, "nullclaw", "1.0.3", script); - - const inst_dir = try mctx.paths.instanceDir(allocator, "nullclaw", "legacy-agent"); - defer allocator.free(inst_dir); - const workspace_dir = try std.fs.path.join(allocator, &.{ inst_dir, "workspace" }); - defer allocator.free(workspace_dir); - try ensurePath(workspace_dir); - - const resp = handleOnboarding(allocator, &s, mctx.paths, "nullclaw", "legacy-agent"); - defer allocator.free(resp.body); - - try std.testing.expectEqualStrings("200 OK", resp.status); - try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"bootstrap_exists\":false") != null); - try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"pending\":true") != null); - try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"completed\":false") != null); - try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"bootstrap_seeded_at\":\"2026-03-13T02:37:27Z\"") != null); -} - -test "handleOnboarding stays idle when legacy sqlite bootstrap memory is absent" { +test "handleOnboarding stays idle when workspace bootstrap state is absent" { const allocator = std.testing.allocator; var state_fixture = try test_helpers.TempPaths.init(allocator); defer state_fixture.deinit(); @@ -6228,17 +6361,6 @@ test "handleOnboarding stays idle when legacy sqlite bootstrap memory is absent" defer mctx.deinit(allocator); try s.addInstance("nullclaw", "empty-agent", .{ .version = "1.0.4" }); - const script = - \\#!/bin/sh - \\if [ "$1" = "memory" ] && [ "$2" = "get" ] && [ "$3" = "__bootstrap.prompt.BOOTSTRAP.md" ]; then - \\ printf '%s\n' 'null' - \\ exit 0 - \\fi - \\echo "unexpected args" >&2 - \\exit 1 - \\ - ; - try writeTestBinary(allocator, mctx.paths, "nullclaw", "1.0.4", script); const inst_dir = try mctx.paths.instanceDir(allocator, "nullclaw", "empty-agent"); defer allocator.free(inst_dir); @@ -6633,10 +6755,6 @@ test "dispatch routes POST integration action for nullboiler" { try std.testing.expect(std.mem.indexOf(u8, workflow, "\"pipeline_id\": \"pipe-dev\"") != null); try std.testing.expect(std.mem.indexOf(u8, workflow, "\"claim_roles\": [\n \"reviewer\"\n ]") != null); try std.testing.expect(std.mem.indexOf(u8, workflow, "\"transition_to\": \"complete\"") != null); - - const legacy_workflow_path = try std.fs.path.join(allocator, &.{ mctx.paths.root, "instances", "nullboiler", "boiler-a", "tracker-workflow.json" }); - defer allocator.free(legacy_workflow_path); - try std.testing.expectError(error.FileNotFound, std_compat.fs.openFileAbsolute(legacy_workflow_path, .{})); } test "dispatch integration relink preserves advanced tracker config and custom workflows" { @@ -6943,7 +7061,7 @@ test "handleHistory returns CLI JSON and passes instance home" { try std.testing.expect(std.mem.indexOf(u8, show_resp.body, "\"role\":\"user\"") != null); } -test "handleMemory wraps legacy CLI failures as JSON errors" { +test "handleMemory wraps CLI failures as JSON errors" { const allocator = std.testing.allocator; var state_fixture = try test_helpers.TempPaths.init(allocator); defer state_fixture.deinit(); @@ -7831,9 +7949,6 @@ test "dispatch routes agent invoke stream and sessions" { try std.testing.expectEqualStrings("200 OK", invoke_resp.status); try std.testing.expect(std.mem.indexOf(u8, invoke_resp.body, "\"response\":\"world\"") != null); - const stream_resp = dispatch(allocator, &s, &mctx.manager, &mctx.mutex, mctx.paths, "POST", "/api/instances/nullclaw/my-agent/agent-stream", "").?; - try std.testing.expectEqualStrings("501 Not Implemented", stream_resp.status); - const list_resp = dispatch(allocator, &s, &mctx.manager, &mctx.mutex, mctx.paths, "GET", "/api/instances/nullclaw/my-agent/agent-sessions", "").?; defer allocator.free(list_resp.body); try std.testing.expectEqualStrings("200 OK", list_resp.status); diff --git a/src/api/logs.zig b/src/api/logs.zig index 5cf1703..b17d1e0 100644 --- a/src/api/logs.zig +++ b/src/api/logs.zig @@ -9,7 +9,6 @@ const test_helpers = @import("../test_helpers.zig"); const ApiResponse = helpers.ApiResponse; const appendEscaped = helpers.appendEscaped; -const SUPERVISOR_PREFIX = "[nullhub/supervisor]"; const MAX_LOG_BYTES = 16 * 1024 * 1024; pub const LogSource = enum { @@ -112,25 +111,13 @@ fn readSourceContents( const logs_dir = p.instanceLogs(allocator, component, name) catch return error.PathBuildFailed; defer allocator.free(logs_dir); - const stdout_log_path = std.fs.path.join(allocator, &.{ logs_dir, "stdout.log" }) catch return error.PathBuildFailed; - defer allocator.free(stdout_log_path); - const stdout_contents = try readFileOrEmpty(allocator, stdout_log_path); - defer allocator.free(stdout_contents); - - return switch (source) { - .instance => filterLegacyStdout(allocator, stdout_contents, .instance), - .nullhub => blk: { - const legacy_nullhub = try filterLegacyStdout(allocator, stdout_contents, .nullhub); - defer allocator.free(legacy_nullhub); - - const nullhub_log_path = std.fs.path.join(allocator, &.{ logs_dir, "nullhub.log" }) catch return error.PathBuildFailed; - defer allocator.free(nullhub_log_path); - const nullhub_contents = try readFileOrEmpty(allocator, nullhub_log_path); - defer allocator.free(nullhub_contents); - - break :blk joinContents(allocator, &.{ legacy_nullhub, nullhub_contents }); - }, + const filename = switch (source) { + .instance => "stdout.log", + .nullhub => "nullhub.log", }; + const log_path = std.fs.path.join(allocator, &.{ logs_dir, filename }) catch return error.PathBuildFailed; + defer allocator.free(log_path); + return readFileOrEmpty(allocator, log_path); } fn readFileOrEmpty(allocator: std.mem.Allocator, path: []const u8) ![]u8 { @@ -142,51 +129,6 @@ fn readFileOrEmpty(allocator: std.mem.Allocator, path: []const u8) ![]u8 { return file.readToEndAlloc(allocator, MAX_LOG_BYTES); } -fn filterLegacyStdout(allocator: std.mem.Allocator, contents: []const u8, source: LogSource) ![]u8 { - var filtered = std.array_list.Managed(u8).init(allocator); - errdefer filtered.deinit(); - - var wrote_any = false; - var line_it = std.mem.splitScalar(u8, contents, '\n'); - while (line_it.next()) |line| { - const is_supervisor_line = std.mem.startsWith(u8, line, SUPERVISOR_PREFIX); - const keep = switch (source) { - .instance => !is_supervisor_line, - .nullhub => is_supervisor_line, - }; - if (!keep) continue; - - if (wrote_any) try filtered.append('\n'); - try filtered.appendSlice(line); - wrote_any = true; - } - - if (wrote_any and contents.len > 0 and contents[contents.len - 1] == '\n') { - try filtered.append('\n'); - } - - if (filtered.items.len == 0) return allocator.dupe(u8, ""); - return filtered.toOwnedSlice(); -} - -fn joinContents(allocator: std.mem.Allocator, parts: []const []const u8) ![]u8 { - var joined = std.array_list.Managed(u8).init(allocator); - errdefer joined.deinit(); - - var wrote_any = false; - for (parts) |part| { - if (part.len == 0) continue; - if (wrote_any and joined.items.len > 0 and joined.items[joined.items.len - 1] != '\n') { - try joined.append('\n'); - } - try joined.appendSlice(part); - wrote_any = true; - } - - if (joined.items.len == 0) return allocator.dupe(u8, ""); - return joined.toOwnedSlice(); -} - fn writeFileBestEffort(path: []const u8, contents: []const u8) void { if (std_compat.fs.createFileAbsolute(path, .{ .truncate = true })) |file| { defer file.close(); @@ -257,48 +199,18 @@ pub fn handleDelete( }; defer allocator.free(logs_dir); - const stdout_log_path = std.fs.path.join(allocator, &.{ logs_dir, "stdout.log" }) catch return .{ - .status = "500 Internal Server Error", - .content_type = "application/json", - .body = "{\"error\":\"internal error\"}", + const filename = switch (source) { + .instance => "stdout.log", + .nullhub => "nullhub.log", }; - defer allocator.free(stdout_log_path); - - const stdout_contents = readFileOrEmpty(allocator, stdout_log_path) catch return .{ + const log_path = std.fs.path.join(allocator, &.{ logs_dir, filename }) catch return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}", }; - defer allocator.free(stdout_contents); - - switch (source) { - .instance => { - const preserved_nullhub = filterLegacyStdout(allocator, stdout_contents, .nullhub) catch return .{ - .status = "500 Internal Server Error", - .content_type = "application/json", - .body = "{\"error\":\"internal error\"}", - }; - defer allocator.free(preserved_nullhub); - writeFileBestEffort(stdout_log_path, preserved_nullhub); - }, - .nullhub => { - const preserved_instance = filterLegacyStdout(allocator, stdout_contents, .instance) catch return .{ - .status = "500 Internal Server Error", - .content_type = "application/json", - .body = "{\"error\":\"internal error\"}", - }; - defer allocator.free(preserved_instance); - writeFileBestEffort(stdout_log_path, preserved_instance); - - const nullhub_log_path = std.fs.path.join(allocator, &.{ logs_dir, "nullhub.log" }) catch return .{ - .status = "500 Internal Server Error", - .content_type = "application/json", - .body = "{\"error\":\"internal error\"}", - }; - defer allocator.free(nullhub_log_path); - writeFileBestEffort(nullhub_log_path, ""); - }, - } + defer allocator.free(log_path); + + writeFileBestEffort(log_path, ""); return .{ .status = "200 OK", @@ -523,7 +435,7 @@ test "handleStream returns SSE snapshot" { try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"line-a\"") != null); } -test "handleGet separates legacy stdout and nullhub logs by source" { +test "handleGet reads dedicated instance and nullhub logs by source" { const allocator = std.testing.allocator; var fixture = try test_helpers.TempPaths.init(allocator); defer fixture.deinit(); @@ -540,12 +452,12 @@ test "handleGet separates legacy stdout and nullhub logs by source" { { const file = try std_compat.fs.createFileAbsolute(stdout_log_path, .{}); defer file.close(); - try file.writeAll("app line 1\n[nullhub/supervisor][1] old diag\napp line 2\n"); + try file.writeAll("app line 1\napp line 2\n"); } { const file = try std_compat.fs.createFileAbsolute(nullhub_log_path, .{}); defer file.close(); - try file.writeAll("[nullhub/supervisor][2] new diag\n"); + try file.writeAll("[nullhub/supervisor][2] dedicated diag\n"); } const instance_resp = handleGet(allocator, fixture.paths, "nullclaw", "my-agent", 100, .instance); @@ -570,9 +482,8 @@ test "handleGet separates legacy stdout and nullhub logs by source" { .{ .allocate = .alloc_always }, ); defer nullhub_parsed.deinit(); - try std.testing.expectEqual(@as(usize, 2), nullhub_parsed.value.lines.len); - try std.testing.expectEqualStrings("[nullhub/supervisor][1] old diag", nullhub_parsed.value.lines[0]); - try std.testing.expectEqualStrings("[nullhub/supervisor][2] new diag", nullhub_parsed.value.lines[1]); + try std.testing.expectEqual(@as(usize, 1), nullhub_parsed.value.lines.len); + try std.testing.expectEqualStrings("[nullhub/supervisor][2] dedicated diag", nullhub_parsed.value.lines[0]); } test "handleDelete clears selected source while preserving the other" { @@ -592,7 +503,7 @@ test "handleDelete clears selected source while preserving the other" { { const file = try std_compat.fs.createFileAbsolute(stdout_log_path, .{}); defer file.close(); - try file.writeAll("app line\n[nullhub/supervisor][1] legacy diag\n"); + try file.writeAll("app line\n"); } { const file = try std_compat.fs.createFileAbsolute(nullhub_log_path, .{}); diff --git a/src/api/meta.zig b/src/api/meta.zig index 0313e21..ddda851 100644 --- a/src/api/meta.zig +++ b/src/api/meta.zig @@ -83,6 +83,13 @@ const channel_id_param = ParamSpec{ .description = "Saved channel numeric identifier.", }; +const mission_replay_id_param = ParamSpec{ + .name = "id", + .location = "path", + .required = true, + .description = "Durable Mission Control replay artifact id.", +}; + const window_query = ParamSpec{ .name = "window", .location = "query", @@ -174,6 +181,13 @@ const memory_limit_query = ParamSpec{ .description = "Maximum number of memory results.", }; +const mission_replay_limit_query = ParamSpec{ + .name = "limit", + .location = "query", + .required = false, + .description = "Maximum number of durable replay records to return, clamped to 1..100.", +}; + const memory_offset_query = ParamSpec{ .name = "offset", .location = "query", @@ -248,6 +262,7 @@ const common_instance_params = [_]ParamSpec{ component_param, instance_name_para const component_only_params = [_]ParamSpec{component_param}; const provider_id_params = [_]ParamSpec{provider_id_param}; const channel_id_params = [_]ParamSpec{channel_id_param}; +const mission_replay_id_params = [_]ParamSpec{mission_replay_id_param}; const module_name_params = [_]ParamSpec{module_name_param}; const component_name_params = [_]ParamSpec{component_name_param}; const wizard_component_params = [_]ParamSpec{wizard_component_param}; @@ -261,6 +276,7 @@ const config_query_params = [_]ParamSpec{config_path_query}; const named_query_params = [_]ParamSpec{named_query}; const session_query_params = [_]ParamSpec{session_query}; const limit_query_params = [_]ParamSpec{history_limit_query}; +const mission_replay_limit_query_params = [_]ParamSpec{mission_replay_limit_query}; const cron_job_id_params = [_]ParamSpec{ component_param, instance_name_param, cron_job_id_param }; const route_examples_status = [_]ExampleSpec{ @@ -412,9 +428,9 @@ const routes = [_]RouteSpec{ .method = "POST", .path_template = "/api/components/refresh", .category = "components", - .summary = "Refresh the component registry and cached manifests.", + .summary = "Return the current component registry snapshot.", .auth_mode = "optional_bearer", - .response = "Refresh status payload.", + .response = "Registry snapshot payload.", }, .{ .id = "wizard.free_port", @@ -548,7 +564,7 @@ const routes = [_]RouteSpec{ .summary = "Create or update a component instance from wizard form data.", .auth_mode = "optional_bearer", .path_params = wizard_component_params[0..], - .body = "Wizard submission JSON.", + .body = "Wizard submission JSON. For nullclaw, optional generic NullHub hints such as nullhub_profile=stateless prepare memory=none, A2A multimodal, media-sized gateway limits, and a NullHub-managed gateway token.", .response = "Created instance payload or validation error.", }, .{ @@ -905,10 +921,44 @@ const routes = [_]RouteSpec{ .method = "POST", .path_template = "/api/instances/{component}/{name}/agent-stream", .category = "instances", - .summary = "Streaming agent turns are not supported through nullhub; this route returns 501.", + .summary = "Stream a managed nullclaw agent turn by translating the request to gateway A2A message/stream.", .auth_mode = "optional_bearer", .path_params = common_instance_params[0..], - .response = "Not implemented payload.", + .body = "JSON body with message and optional session_key. Provider/model overrides are not applied on this gateway-backed streaming route.", + .response = "text/event-stream A2A task events.", + }, + .{ + .id = "instances.a2a", + .method = "POST", + .path_template = "/api/instances/{component}/{name}/a2a", + .category = "instances", + .summary = "Proxy a JSON-RPC A2A request to a managed nullclaw gateway.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .body = "A2A JSON-RPC request body.", + .response = "A2A JSON-RPC response payload.", + }, + .{ + .id = "instances.a2a.stream", + .method = "POST", + .path_template = "/api/instances/{component}/{name}/a2a-stream", + .category = "instances", + .summary = "Proxy a streaming A2A JSON-RPC request to a managed nullclaw gateway.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .body = "A2A JSON-RPC request body using a streaming method.", + .response = "text/event-stream A2A response.", + }, + .{ + .id = "instances.transcribe", + .method = "POST", + .path_template = "/api/instances/{component}/{name}/transcribe", + .category = "instances", + .summary = "Transcribe audio through a managed nullclaw gateway.", + .auth_mode = "optional_bearer", + .path_params = common_instance_params[0..], + .body = "JSON body with audio_base64, mime_type, source, and optional language.", + .response = "Transcript JSON payload.", }, .{ .id = "instances.agent.sessions", @@ -1061,7 +1111,7 @@ const routes = [_]RouteSpec{ .method = "GET", .path_template = "/api/instances/{component}/{name}/integration", .category = "instances", - .summary = "Read integration status for linked telemetry, orchestration, and tracker components.", + .summary = "Read integration status for linked NullWatch telemetry, NullBoiler workflow, and NullTickets tracker components.", .auth_mode = "optional_bearer", .path_params = common_instance_params[0..], .response = "Integration status and linkage payload.", @@ -1324,25 +1374,113 @@ const routes = [_]RouteSpec{ .response = "Update result payload.", }, .{ - .id = "orchestration.proxy", + .id = "nullboiler.proxy", + .method = "ANY", + .path_template = "/api/nullboiler/{...}", + .category = "nullboiler", + .summary = "Proxy NullBoiler workflow and run requests.", + .auth_mode = "optional_bearer", + .body = "Forwarded as-is to NullBoiler.", + .response = "Forwarded upstream JSON response.", + }, + .{ + .id = "nulltickets.store.proxy", .method = "ANY", - .path_template = "/api/orchestration/{...}", - .category = "orchestration", - .summary = "Proxy orchestration requests to NullBoiler, or store requests to NullTickets.", + .path_template = "/api/nulltickets/store/{...}", + .category = "nulltickets", + .summary = "Proxy NullTickets durable store requests.", .auth_mode = "optional_bearer", - .body = "Forwarded as-is to the orchestration backend.", + .body = "Forwarded as-is to NullTickets.", .response = "Forwarded upstream JSON response.", }, .{ - .id = "observability.proxy", + .id = "nullwatch.proxy", .method = "ANY", - .path_template = "/api/observability/{...}", - .category = "observability", - .summary = "Proxy observability requests to a managed or configured NullWatch instance.", + .path_template = "/api/nullwatch/{...}", + .category = "nullwatch", + .summary = "Proxy NullWatch tracing and eval requests to a managed or configured instance.", .auth_mode = "optional_bearer", .body = "Forwarded as-is to NullWatch.", .response = "Forwarded upstream JSON response.", }, + .{ + .id = "mission-control.state", + .method = "GET", + .path_template = "/api/mission-control/state", + .category = "mission-control", + .summary = "Read the local deterministic NullOS Mission Control replay state.", + .auth_mode = "optional_bearer", + .response = "Schema-versioned mission state, controls, graph, timeline, telemetry, recovery metadata, NullWatch-style trace references, and resolved NullBoiler workflow evidence.", + }, + .{ + .id = "mission-control.replay", + .method = "GET", + .path_template = "/api/mission-control/replay", + .category = "mission-control", + .summary = "Export the current Mission Control replay artifact for local review and debugging.", + .auth_mode = "optional_bearer", + .response = "Replay artifact with current snapshot, source fixture, failed/recovered artifact comparison, resolved NullBoiler workflow evidence, and NullTickets/NullBoiler/NullClaw/NullWatch mapping metadata.", + }, + .{ + .id = "mission-control.replay.save", + .method = "POST", + .path_template = "/api/mission-control/replay/save", + .category = "mission-control", + .summary = "Persist the completed Mission Control replay artifact in local NullHub storage.", + .auth_mode = "optional_bearer", + .body = "No request body required.", + .response = "Saved replay record and the captured completed replay artifact; returns 409 before recovered validation completes.", + }, + .{ + .id = "mission-control.replays.list", + .method = "GET", + .path_template = "/api/mission-control/replays", + .category = "mission-control", + .summary = "List durable Mission Control replay records.", + .auth_mode = "optional_bearer", + .query_params = mission_replay_limit_query_params[0..], + .response = "Recent replay records from local NullHub storage.", + }, + .{ + .id = "mission-control.replays.get", + .method = "GET", + .path_template = "/api/mission-control/replays/{id}", + .category = "mission-control", + .summary = "Read a durable Mission Control replay artifact by id.", + .auth_mode = "optional_bearer", + .path_params = mission_replay_id_params[0..], + .response = "The stored replay artifact JSON.", + }, + .{ + .id = "mission-control.reset", + .method = "POST", + .path_template = "/api/mission-control/reset", + .category = "mission-control", + .summary = "Reset the local Mission Control replay to idle.", + .auth_mode = "optional_bearer", + .body = "No request body required.", + .response = "Reset mission state.", + }, + .{ + .id = "mission-control.launch", + .method = "POST", + .path_template = "/api/mission-control/launch", + .category = "mission-control", + .summary = "Launch the local Mission Control replay if it is idle.", + .auth_mode = "optional_bearer", + .body = "No request body required.", + .response = "Mission state after launch, or 409 when already started.", + }, + .{ + .id = "mission-control.recover", + .method = "POST", + .path_template = "/api/mission-control/recover", + .category = "mission-control", + .summary = "Fork the local Mission Control replay after the failure phase.", + .auth_mode = "optional_bearer", + .body = "No request body required.", + .response = "Mission state after checkpoint recovery, or 409 before recovery is allowed.", + }, }; pub fn allRoutes() []const RouteSpec { diff --git a/src/api/mission_control.zig b/src/api/mission_control.zig new file mode 100644 index 0000000..a51ac01 --- /dev/null +++ b/src/api/mission_control.zig @@ -0,0 +1,418 @@ +const std = @import("std"); +const std_compat = @import("compat"); +const helpers = @import("helpers.zig"); +const query = @import("query.zig"); +const mission = @import("../core/mission_control.zig"); +const replay_store = @import("../core/mission_replay_store.zig"); +const paths_mod = @import("../core/paths.zig"); + +const ApiResponse = helpers.ApiResponse; + +const prefix = "/api/mission-control"; + +pub const RuntimeStore = struct { + mutex: std_compat.sync.Mutex = .{}, + runtime: mission.RuntimeState = .{}, +}; + +pub const WorkflowEvidenceResolver = struct { + ptr: *anyopaque, + resolve: *const fn (*anyopaque, std.mem.Allocator, mission.WorkflowEvidenceRefs) mission.WorkflowEvidence, + + fn run(self: WorkflowEvidenceResolver, allocator: std.mem.Allocator, refs: mission.WorkflowEvidenceRefs) mission.WorkflowEvidence { + return self.resolve(self.ptr, allocator, refs); + } +}; + +pub const Integrations = struct { + paths: ?paths_mod.Paths = null, + workflow_evidence_resolver: ?WorkflowEvidenceResolver = null, +}; + +pub fn isPath(target: []const u8) bool { + const path = query.stripTarget(target); + return std.mem.eql(u8, path, prefix) or std.mem.startsWith(u8, path, prefix ++ "/"); +} + +pub fn handleWithIntegrations(allocator: std.mem.Allocator, method: []const u8, target: []const u8, store: *RuntimeStore, integrations: Integrations) ApiResponse { + const path = query.stripTarget(target); + if (!isPath(path)) return helpers.notFound(); + + const is_state = std.mem.eql(u8, path, prefix ++ "/state"); + const is_replay = std.mem.eql(u8, path, prefix ++ "/replay"); + const is_replay_save = std.mem.eql(u8, path, prefix ++ "/replay/save"); + const is_replays = std.mem.eql(u8, path, prefix ++ "/replays"); + const stored_replay_id = storedReplayId(path); + const is_reset = std.mem.eql(u8, path, prefix ++ "/reset"); + const is_launch = std.mem.eql(u8, path, prefix ++ "/launch"); + const is_recover = std.mem.eql(u8, path, prefix ++ "/recover"); + if (!is_state and !is_replay and !is_replay_save and !is_replays and stored_replay_id == null and !is_reset and !is_launch and !is_recover) return helpers.notFound(); + + if (is_state or is_replay or is_replays or stored_replay_id != null) { + if (!std.mem.eql(u8, method, "GET")) return helpers.methodNotAllowed(); + } else if (is_replay_save) { + if (!std.mem.eql(u8, method, "POST")) return helpers.methodNotAllowed(); + } else if (!std.mem.eql(u8, method, "POST")) { + return helpers.methodNotAllowed(); + } + + const now_ms = std_compat.time.milliTimestamp(); + + if (is_state) return stateResponse(allocator, runtimeSnapshot(store), now_ms, integrations); + if (is_replay) return replayResponse(allocator, runtimeSnapshot(store), now_ms, integrations); + if (is_replay_save) return saveReplayResponse(allocator, runtimeSnapshot(store), now_ms, integrations); + if (is_replays) return listReplayResponse(allocator, target, integrations); + if (stored_replay_id) |id| return storedReplayResponse(allocator, id, integrations); + + if (is_reset) { + store.mutex.lock(); + mission.reset(&store.runtime); + const runtime = store.runtime; + store.mutex.unlock(); + return stateResponse(allocator, runtime, now_ms, integrations); + } + + if (is_launch) { + store.mutex.lock(); + const launched = mission.launch(&store.runtime, now_ms); + const runtime = store.runtime; + store.mutex.unlock(); + + if (!launched) return missionAlreadyStarted(); + return stateResponse(allocator, runtime, now_ms, integrations); + } + + if (is_recover) { + var parsed = mission.parseReplay(allocator) catch return helpers.serverError(); + defer parsed.deinit(); + + store.mutex.lock(); + const recovered = mission.recoverWithFixture(parsed.value, &store.runtime, now_ms); + const runtime = store.runtime; + store.mutex.unlock(); + + if (!recovered) return missionNotRecoverable(); + return stateResponse(allocator, runtime, now_ms, integrations); + } + + return helpers.notFound(); +} + +fn runtimeSnapshot(store: *RuntimeStore) mission.RuntimeState { + store.mutex.lock(); + defer store.mutex.unlock(); + return store.runtime; +} + +fn stateResponse(allocator: std.mem.Allocator, runtime: mission.RuntimeState, now_ms: i64, integrations: Integrations) ApiResponse { + const parsed = mission.parseReplay(allocator) catch return helpers.serverError(); + const workflow_evidence = resolveWorkflowEvidence(allocator, integrations, mission.workflowEvidenceRefs(parsed.value)); + var view = mission.buildSnapshotViewFromParsed(allocator, parsed, runtime, now_ms, workflow_evidence) catch return helpers.serverError(); + defer view.deinit(allocator); + + const body = std.json.Stringify.valueAlloc(allocator, view.snapshot, .{ .whitespace = .indent_2 }) catch return helpers.serverError(); + return helpers.jsonOk(body); +} + +fn replayResponse(allocator: std.mem.Allocator, runtime: mission.RuntimeState, now_ms: i64, integrations: Integrations) ApiResponse { + const parsed = mission.parseReplay(allocator) catch return helpers.serverError(); + const workflow_evidence = resolveWorkflowEvidence(allocator, integrations, mission.workflowEvidenceRefs(parsed.value)); + var view = mission.buildReplayArtifactViewFromParsed(allocator, parsed, runtime, now_ms, workflow_evidence) catch return helpers.serverError(); + defer view.deinit(allocator); + + const body = std.json.Stringify.valueAlloc(allocator, view.artifact, .{ .whitespace = .indent_2 }) catch return helpers.serverError(); + return helpers.jsonOk(body); +} + +fn saveReplayResponse(allocator: std.mem.Allocator, runtime: mission.RuntimeState, now_ms: i64, integrations: Integrations) ApiResponse { + const paths = integrations.paths orelse return helpers.serverError(); + const parsed = mission.parseReplay(allocator) catch return helpers.serverError(); + const workflow_evidence = resolveWorkflowEvidence(allocator, integrations, mission.workflowEvidenceRefs(parsed.value)); + var view = mission.buildReplayArtifactViewFromParsed(allocator, parsed, runtime, now_ms, workflow_evidence) catch return helpers.serverError(); + defer view.deinit(allocator); + if (!std.mem.eql(u8, view.artifact.snapshot.status, "completed")) return replayNotComplete(); + + const artifact_body = std.json.Stringify.valueAlloc(allocator, view.artifact, .{ .whitespace = .indent_2 }) catch return helpers.serverError(); + defer allocator.free(artifact_body); + + var record = replay_store.save(allocator, paths, now_ms, artifact_body) catch return helpers.serverError(); + defer record.deinit(allocator); + + const response = struct { + record: replay_store.Record, + artifact: mission.ReplayArtifact, + }{ + .record = record, + .artifact = view.artifact, + }; + const body = std.json.Stringify.valueAlloc(allocator, response, .{ .whitespace = .indent_2 }) catch return helpers.serverError(); + return helpers.jsonOk(body); +} + +fn listReplayResponse(allocator: std.mem.Allocator, target: []const u8, integrations: Integrations) ApiResponse { + const paths = integrations.paths orelse return helpers.serverError(); + const requested_limit = query.usizeValue(target, "limit", 25); + const limit = std.math.clamp(requested_limit, 1, 100); + const records = replay_store.list(allocator, paths, limit) catch return helpers.serverError(); + defer replay_store.deinitRecords(allocator, records); + + const response = struct { + items: []const replay_store.Record, + count: usize, + }{ + .items = records, + .count = records.len, + }; + const body = std.json.Stringify.valueAlloc(allocator, response, .{ .whitespace = .indent_2 }) catch return helpers.serverError(); + return helpers.jsonOk(body); +} + +fn storedReplayResponse(allocator: std.mem.Allocator, id: []const u8, integrations: Integrations) ApiResponse { + const paths = integrations.paths orelse return helpers.serverError(); + const body = replay_store.read(allocator, paths, id) catch |err| switch (err) { + error.FileNotFound, error.InvalidReplayId => return helpers.notFound(), + error.InvalidReplayArtifact => return invalidReplayArtifact(), + else => return helpers.serverError(), + }; + return helpers.jsonOk(body); +} + +fn storedReplayId(path: []const u8) ?[]const u8 { + const item_prefix = prefix ++ "/replays/"; + if (!std.mem.startsWith(u8, path, item_prefix)) return null; + const id = path[item_prefix.len..]; + if (id.len == 0 or std.mem.indexOfScalar(u8, id, '/') != null) return null; + return id; +} + +fn resolveWorkflowEvidence(allocator: std.mem.Allocator, integrations: Integrations, refs: mission.WorkflowEvidenceRefs) mission.WorkflowEvidence { + if (integrations.workflow_evidence_resolver) |resolver| return resolver.run(allocator, refs); + return mission.workflowEvidenceUnavailable("not_configured"); +} + +fn missionAlreadyStarted() ApiResponse { + return .{ + .status = "409 Conflict", + .content_type = "application/json", + .body = "{\"error\":{\"code\":\"mission_already_started\",\"message\":\"Mission is already started. Reset before launching again.\"}}", + }; +} + +fn missionNotRecoverable() ApiResponse { + return .{ + .status = "409 Conflict", + .content_type = "application/json", + .body = "{\"error\":{\"code\":\"mission_not_recoverable\",\"message\":\"Mission can only be recovered after the validation failure phase.\"}}", + }; +} + +fn replayNotComplete() ApiResponse { + return .{ + .status = "409 Conflict", + .content_type = "application/json", + .body = "{\"error\":{\"code\":\"mission_replay_not_complete\",\"message\":\"Mission replay can only be saved after recovered validation completes.\"}}", + }; +} + +fn invalidReplayArtifact() ApiResponse { + return .{ + .status = "422 Unprocessable Entity", + .content_type = "application/json", + .body = "{\"error\":{\"code\":\"invalid_replay_artifact\",\"message\":\"Stored mission replay artifact failed schema validation.\"}}", + }; +} + +test "isPath matches mission-control namespace" { + try std.testing.expect(isPath("/api/mission-control/state")); + try std.testing.expect(isPath("/api/mission-control/replay")); + try std.testing.expect(isPath("/api/mission-control/reset")); + try std.testing.expect(isPath("/api/mission-control/state?poll=1")); + try std.testing.expect(!isPath("/api/nullwatch/v1/runs")); +} + +var test_workflow_resolver_state: u8 = 0; + +fn handleForTest(allocator: std.mem.Allocator, method: []const u8, target: []const u8, store: *RuntimeStore) ApiResponse { + return handleWithIntegrations(allocator, method, target, store, .{ + .workflow_evidence_resolver = .{ + .ptr = &test_workflow_resolver_state, + .resolve = testWorkflowEvidenceResolver, + }, + }); +} + +fn handleForTestWithPaths(allocator: std.mem.Allocator, method: []const u8, target: []const u8, store: *RuntimeStore, paths: paths_mod.Paths) ApiResponse { + return handleWithIntegrations(allocator, method, target, store, .{ + .paths = paths, + .workflow_evidence_resolver = .{ + .ptr = &test_workflow_resolver_state, + .resolve = testWorkflowEvidenceResolver, + }, + }); +} + +fn testWorkflowEvidenceResolver(_: *anyopaque, _: std.mem.Allocator, refs: mission.WorkflowEvidenceRefs) mission.WorkflowEvidence { + return .{ + .status = "available", + .failed_run = .{ + .run_id = refs.failed_run_id, + .status = "failed", + .checkpoint_count = 1, + }, + .recovered_run = .{ + .run_id = refs.recovered_run_id, + .status = "completed", + .checkpoint_count = 1, + }, + .checkpoint = .{ + .id = refs.checkpoint_id, + .run_id = refs.failed_run_id, + .step_id = "code.build", + }, + .scanned_run_count = 2, + }; +} + +test "handle supports reset launch and recovery after failure" { + var store = RuntimeStore{}; + const reset = handleForTest(std.testing.allocator, "POST", "/api/mission-control/reset", &store); + defer std.testing.allocator.free(reset.body); + try std.testing.expectEqualStrings("200 OK", reset.status); + + const launched = handleForTest(std.testing.allocator, "POST", "/api/mission-control/launch", &store); + defer std.testing.allocator.free(launched.body); + try std.testing.expectEqualStrings("200 OK", launched.status); + try std.testing.expect(std.mem.indexOf(u8, launched.body, "\"status\": \"running\"") != null); + + store.mutex.lock(); + store.runtime = .{ + .launched = true, + .started_at_ms = std_compat.time.milliTimestamp() - 10_000, + }; + store.mutex.unlock(); + + const recovered = handleForTest(std.testing.allocator, "POST", "/api/mission-control/recover", &store); + defer std.testing.allocator.free(recovered.body); + try std.testing.expectEqualStrings("200 OK", recovered.status); + try std.testing.expect(std.mem.indexOf(u8, recovered.body, "\"recovered_run_id\": \"run-mission-code-red-recovered\"") != null); +} + +test "handle rejects invalid mission transitions" { + var store = RuntimeStore{}; + const reset = handleForTest(std.testing.allocator, "POST", "/api/mission-control/reset", &store); + defer std.testing.allocator.free(reset.body); + try std.testing.expectEqualStrings("200 OK", reset.status); + + const early_recover = handleForTest(std.testing.allocator, "POST", "/api/mission-control/recover", &store); + try std.testing.expectEqualStrings("409 Conflict", early_recover.status); + try std.testing.expect(std.mem.indexOf(u8, early_recover.body, "mission_not_recoverable") != null); + + const launched = handleForTest(std.testing.allocator, "POST", "/api/mission-control/launch", &store); + defer std.testing.allocator.free(launched.body); + try std.testing.expectEqualStrings("200 OK", launched.status); + + const duplicate_launch = handleForTest(std.testing.allocator, "POST", "/api/mission-control/launch", &store); + try std.testing.expectEqualStrings("409 Conflict", duplicate_launch.status); + try std.testing.expect(std.mem.indexOf(u8, duplicate_launch.body, "mission_already_started") != null); +} + +test "handle keeps mission runtime scoped to the provided store" { + var first_store = RuntimeStore{}; + var second_store = RuntimeStore{}; + + const launched = handleForTest(std.testing.allocator, "POST", "/api/mission-control/launch", &first_store); + defer std.testing.allocator.free(launched.body); + try std.testing.expectEqualStrings("200 OK", launched.status); + + const first_state = handleForTest(std.testing.allocator, "GET", "/api/mission-control/state", &first_store); + defer std.testing.allocator.free(first_state.body); + try std.testing.expect(std.mem.indexOf(u8, first_state.body, "\"status\": \"running\"") != null); + + const second_state = handleForTest(std.testing.allocator, "GET", "/api/mission-control/state", &second_store); + defer std.testing.allocator.free(second_state.body); + try std.testing.expect(std.mem.indexOf(u8, second_state.body, "\"status\": \"idle\"") != null); + try std.testing.expect(std.mem.indexOf(u8, second_state.body, "\"can_launch\": true") != null); +} + +test "handle returns clear status codes for unknown paths and methods" { + var store = RuntimeStore{}; + const unknown_get = handleForTest(std.testing.allocator, "GET", "/api/mission-control/nope", &store); + try std.testing.expectEqualStrings("404 Not Found", unknown_get.status); + + const wrong_method = handleForTest(std.testing.allocator, "GET", "/api/mission-control/launch", &store); + try std.testing.expectEqualStrings("405 Method Not Allowed", wrong_method.status); + + const wrong_replay_method = handleForTest(std.testing.allocator, "POST", "/api/mission-control/replay", &store); + try std.testing.expectEqualStrings("405 Method Not Allowed", wrong_replay_method.status); +} + +test "handle returns replay artifact" { + var store = RuntimeStore{}; + const reset = handleForTest(std.testing.allocator, "POST", "/api/mission-control/reset", &store); + defer std.testing.allocator.free(reset.body); + + const replay_resp = handleForTest(std.testing.allocator, "GET", "/api/mission-control/replay", &store); + defer std.testing.allocator.free(replay_resp.body); + + try std.testing.expectEqualStrings("200 OK", replay_resp.status); + try std.testing.expect(std.mem.indexOf(u8, replay_resp.body, "\"artifact_schema_version\": 1") != null); + try std.testing.expect(std.mem.indexOf(u8, replay_resp.body, "\"artifact_kind\": \"nullhub.mission_control.replay\"") != null); + try std.testing.expect(std.mem.indexOf(u8, replay_resp.body, "\"replay_comparison\"") != null); + try std.testing.expect(std.mem.indexOf(u8, replay_resp.body, "\"artifact_role\": \"failed\"") != null); + try std.testing.expect(std.mem.indexOf(u8, replay_resp.body, "\"workflow_evidence\"") != null); + try std.testing.expect(std.mem.indexOf(u8, replay_resp.body, "\"scanned_run_count\": 2") != null); +} + +test "handle saves lists and reads durable replay artifacts" { + const allocator = std.testing.allocator; + const test_helpers = @import("../test_helpers.zig"); + + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + + var store = RuntimeStore{}; + store.runtime = .{ + .launched = true, + .started_at_ms = std_compat.time.milliTimestamp() - 20_000, + .recovered = true, + .recovery_started_at_ms = std_compat.time.milliTimestamp() - 12_000, + }; + + const save_resp = handleForTestWithPaths(allocator, "POST", "/api/mission-control/replay/save", &store, fixture.paths); + defer allocator.free(save_resp.body); + try std.testing.expectEqualStrings("200 OK", save_resp.status); + try std.testing.expect(std.mem.indexOf(u8, save_resp.body, "\"record\"") != null); + try std.testing.expect(std.mem.indexOf(u8, save_resp.body, "\"artifact\"") != null); + + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, save_resp.body, .{ .allocate = .alloc_always }); + defer parsed.deinit(); + const record = parsed.value.object.get("record").?.object; + const id = record.get("id").?.string; + + const list_resp = handleForTestWithPaths(allocator, "GET", "/api/mission-control/replays", &store, fixture.paths); + defer allocator.free(list_resp.body); + try std.testing.expectEqualStrings("200 OK", list_resp.status); + try std.testing.expect(std.mem.indexOf(u8, list_resp.body, id) != null); + try std.testing.expect(std.mem.indexOf(u8, list_resp.body, "\"phase\": \"completed\"") != null); + + const read_path = try std.fmt.allocPrint(allocator, "/api/mission-control/replays/{s}", .{id}); + defer allocator.free(read_path); + const read_resp = handleForTestWithPaths(allocator, "GET", read_path, &store, fixture.paths); + defer allocator.free(read_resp.body); + try std.testing.expectEqualStrings("200 OK", read_resp.status); + try std.testing.expect(std.mem.indexOf(u8, read_resp.body, "\"artifact_kind\": \"nullhub.mission_control.replay\"") != null); + try std.testing.expect(std.mem.indexOf(u8, read_resp.body, "\"phase\": \"completed\"") != null); +} + +test "handle rejects saving incomplete replay snapshots" { + const allocator = std.testing.allocator; + const test_helpers = @import("../test_helpers.zig"); + + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + + var store = RuntimeStore{}; + const save_resp = handleForTestWithPaths(allocator, "POST", "/api/mission-control/replay/save", &store, fixture.paths); + try std.testing.expectEqualStrings("409 Conflict", save_resp.status); + try std.testing.expect(std.mem.indexOf(u8, save_resp.body, "mission_replay_not_complete") != null); +} diff --git a/src/api/nullboiler.zig b/src/api/nullboiler.zig new file mode 100644 index 0000000..396ba57 --- /dev/null +++ b/src/api/nullboiler.zig @@ -0,0 +1,136 @@ +const std = @import("std"); +const http_proxy = @import("proxy.zig"); +const query_api = @import("query.zig"); + +const Allocator = std.mem.Allocator; +const Response = http_proxy.Response; + +const prefix = "/api/nullboiler"; + +pub const Config = struct { + boiler_url: ?[]const u8 = null, + boiler_token: ?[]const u8 = null, +}; + +pub fn isProxyPath(target: []const u8) bool { + return http_proxy.isTargetInNamespace(target, prefix); +} + +pub fn requestedBoilerInstance(allocator: Allocator, target: []const u8) !?[]u8 { + if (!isProxyPath(target)) return null; + const value = (try query_api.valueAlloc(allocator, target, "boiler_instance")) orelse return null; + if (value.len == 0) { + allocator.free(value); + return null; + } + return value; +} + +/// Proxies NullBoiler API requests. The shared `/api/nullboiler` prefix is +/// stripped before forwarding, so `/api/nullboiler/runs` becomes `/runs`. +pub fn handle(allocator: Allocator, method: []const u8, target: []const u8, body: []const u8, cfg: Config) Response { + if (!isProxyPath(target)) { + return .{ .status = "404 Not Found", .content_type = "application/json", .body = "{\"error\":\"not found\"}" }; + } + + const base_url = cfg.boiler_url orelse + return .{ .status = "503 Service Unavailable", .content_type = "application/json", .body = "{\"error\":\"NullBoiler not configured\"}" }; + + const selector_params = [_][]const u8{"boiler_instance"}; + var forwarded = http_proxy.rewriteProductProxyTarget(allocator, target, .{ + .prefix = prefix, + .selector_params = selector_params[0..], + .default_path = "/", + }) catch + return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" }; + defer forwarded.deinit(allocator); + + return http_proxy.forward(allocator, .{ + .method = method, + .base_url = base_url, + .path = forwarded.path, + .body = body, + .bearer_token = cfg.boiler_token, + .unreachable_body = "{\"error\":\"NullBoiler unreachable\"}", + }); +} + +test "isProxyPath matches NullBoiler namespace" { + try std.testing.expect(isProxyPath("/api/nullboiler")); + try std.testing.expect(isProxyPath("/api/nullboiler?boiler_instance=worker-a")); + try std.testing.expect(isProxyPath("/api/nullboiler/runs")); + try std.testing.expect(!isProxyPath("/api/nulltickets/store/search")); + try std.testing.expect(!isProxyPath("/api/nullwatch/v1/runs")); + try std.testing.expect(!isProxyPath("/api/instances")); +} + +test "requestedBoilerInstance decodes NullBoiler target selection" { + const allocator = std.testing.allocator; + const value = (try requestedBoilerInstance(allocator, "/api/nullboiler/workflows?boiler_instance=boiler%20a")).?; + defer allocator.free(value); + try std.testing.expectEqualStrings("boiler a", value); + try std.testing.expect(try requestedBoilerInstance(allocator, "/api/nulltickets/store/ns?boiler_instance=boiler-a") == null); +} + +test "rewriteProductProxyTarget strips only NullBoiler selector params" { + const allocator = std.testing.allocator; + const selector_params = [_][]const u8{"boiler_instance"}; + var forwarded = try http_proxy.rewriteProductProxyTarget(allocator, "/api/nullboiler/runs?boiler_instance=boiler-a&status=running", .{ + .prefix = prefix, + .selector_params = selector_params[0..], + }); + defer forwarded.deinit(allocator); + try std.testing.expectEqualStrings("/api/nullboiler/runs?status=running", forwarded.target); + try std.testing.expectEqualStrings("/runs?status=running", forwarded.path); + + var upstream_filter = try http_proxy.rewriteProductProxyTarget(allocator, "/api/nullboiler/runs?worker=primary", .{ + .prefix = prefix, + .selector_params = selector_params[0..], + }); + defer upstream_filter.deinit(allocator); + try std.testing.expectEqualStrings("/api/nullboiler/runs?worker=primary", upstream_filter.target); + try std.testing.expectEqualStrings("/runs?worker=primary", upstream_filter.path); +} + +test "handle returns not configured without NullBoiler URL" { + const resp = handle(std.testing.allocator, "GET", "/api/nullboiler/runs", "", .{}); + try std.testing.expectEqualStrings("503 Service Unavailable", resp.status); + try std.testing.expectEqualStrings("{\"error\":\"NullBoiler not configured\"}", resp.body); +} + +test "handle returns 404 for non-NullBoiler paths" { + const resp = handle(std.testing.allocator, "GET", "/api/status", "", .{}); + try std.testing.expectEqualStrings("404 Not Found", resp.status); + try std.testing.expectEqualStrings("{\"error\":\"not found\"}", resp.body); +} + +test "handle rejects unsupported methods before fetch" { + const resp = handle(std.testing.allocator, "HEAD", "/api/nullboiler/runs", "", .{ + .boiler_url = "http://127.0.0.1:8080", + }); + try std.testing.expectEqualStrings("405 Method Not Allowed", resp.status); + try std.testing.expectEqualStrings("{\"error\":\"method not allowed\"}", resp.body); +} + +test "handle passes through upstream 409 status and body" { + if (comptime @import("builtin").os.tag == .windows) return error.SkipZigTest; + + const allocator = std.testing.allocator; + var upstream = try http_proxy.TestUpstream.start(allocator, "HTTP/1.1 409 Conflict\r\nContent-Type: application/json\r\nContent-Length: 20\r\n\r\n{\"error\":\"conflict\"}"); + defer upstream.deinit(); + + const base_url = try upstream.baseUrl(allocator); + defer allocator.free(base_url); + + const resp = handle(allocator, "GET", "/api/nullboiler/runs?boiler_instance=boiler-a&status=running", "", .{ + .boiler_url = base_url, + .boiler_token = "boiler-token", + }); + defer allocator.free(resp.body); + + try std.testing.expectEqualStrings("409 Conflict", resp.status); + try std.testing.expectEqualStrings("{\"error\":\"conflict\"}", resp.body); + try std.testing.expect(std.mem.indexOf(u8, upstream.request(), "GET /runs?status=running HTTP/1.1") != null); + try std.testing.expect(std.mem.indexOf(u8, upstream.request(), "Authorization: Bearer boiler-token") != null); + try std.testing.expect(std.mem.indexOf(u8, upstream.request(), "boiler_instance") == null); +} diff --git a/src/api/nulltickets.zig b/src/api/nulltickets.zig new file mode 100644 index 0000000..6dae929 --- /dev/null +++ b/src/api/nulltickets.zig @@ -0,0 +1,140 @@ +const std = @import("std"); +const http_proxy = @import("proxy.zig"); +const query_api = @import("query.zig"); + +const Allocator = std.mem.Allocator; +const Response = http_proxy.Response; + +const prefix = "/api/nulltickets"; +const store_prefix = "/api/nulltickets/store"; + +pub const Config = struct { + tickets_url: ?[]const u8 = null, + tickets_token: ?[]const u8 = null, +}; + +pub fn isProxyPath(target: []const u8) bool { + const clean = query_api.stripTarget(target); + return std.mem.eql(u8, clean, store_prefix) or std.mem.startsWith(u8, clean, store_prefix ++ "/"); +} + +pub fn requestedTicketsInstance(allocator: Allocator, target: []const u8) !?[]u8 { + if (!isProxyPath(target)) return null; + const value = (try query_api.valueAlloc(allocator, target, "tickets_instance")) orelse return null; + if (value.len == 0) { + allocator.free(value); + return null; + } + return value; +} + +/// Proxies NullTickets store API requests. The `/api/nulltickets` prefix is +/// stripped before forwarding, so `/api/nulltickets/store/ns` becomes `/store/ns`. +pub fn handle(allocator: Allocator, method: []const u8, target: []const u8, body: []const u8, cfg: Config) Response { + if (!isProxyPath(target)) { + return .{ .status = "404 Not Found", .content_type = "application/json", .body = "{\"error\":\"not found\"}" }; + } + + const base_url = cfg.tickets_url orelse + return .{ .status = "503 Service Unavailable", .content_type = "application/json", .body = "{\"error\":\"NullTickets not configured\"}" }; + + const selector_params = [_][]const u8{"tickets_instance"}; + var forwarded = http_proxy.rewriteProductProxyTarget(allocator, target, .{ + .prefix = prefix, + .selector_params = selector_params[0..], + .default_path = "/", + }) catch + return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" }; + defer forwarded.deinit(allocator); + + return http_proxy.forward(allocator, .{ + .method = method, + .base_url = base_url, + .path = forwarded.path, + .body = body, + .bearer_token = cfg.tickets_token, + .unreachable_body = "{\"error\":\"NullTickets unreachable\"}", + }); +} + +test "isProxyPath matches NullTickets store namespace" { + try std.testing.expect(isProxyPath("/api/nulltickets/store")); + try std.testing.expect(isProxyPath("/api/nulltickets/store?tickets_instance=tracker-a")); + try std.testing.expect(isProxyPath("/api/nulltickets/store/search")); + try std.testing.expect(!isProxyPath("/api/nulltickets")); + try std.testing.expect(!isProxyPath("/api/nullboiler/runs")); + try std.testing.expect(!isProxyPath("/api/nulltickets/tasks")); +} + +test "requestedTicketsInstance decodes store target selection" { + const allocator = std.testing.allocator; + const value = (try requestedTicketsInstance(allocator, "/api/nulltickets/store/ns?tickets_instance=tracker%20a")).?; + defer allocator.free(value); + try std.testing.expectEqualStrings("tracker a", value); + try std.testing.expect(try requestedTicketsInstance(allocator, "/api/nullboiler/runs?tickets_instance=tracker-a") == null); +} + +test "rewriteProductProxyTarget strips only NullTickets selector params" { + const allocator = std.testing.allocator; + const selector_params = [_][]const u8{"tickets_instance"}; + var forwarded = try http_proxy.rewriteProductProxyTarget(allocator, "/api/nulltickets/store/search?q=tasks&tickets_instance=tracker-a&limit=10", .{ + .prefix = prefix, + .selector_params = selector_params[0..], + }); + defer forwarded.deinit(allocator); + try std.testing.expectEqualStrings("/api/nulltickets/store/search?q=tasks&limit=10", forwarded.target); + try std.testing.expectEqualStrings("/store/search?q=tasks&limit=10", forwarded.path); + + var upstream_filter = try http_proxy.rewriteProductProxyTarget(allocator, "/api/nulltickets/store/search?owner=team-a", .{ + .prefix = prefix, + .selector_params = selector_params[0..], + }); + defer upstream_filter.deinit(allocator); + try std.testing.expectEqualStrings("/api/nulltickets/store/search?owner=team-a", upstream_filter.target); + try std.testing.expectEqualStrings("/store/search?owner=team-a", upstream_filter.path); +} + +test "handle returns not configured without NullTickets URL" { + const resp = handle(std.testing.allocator, "GET", "/api/nulltickets/store/search", "", .{}); + try std.testing.expectEqualStrings("503 Service Unavailable", resp.status); + try std.testing.expectEqualStrings("{\"error\":\"NullTickets not configured\"}", resp.body); +} + +test "handle returns 404 for non-store NullTickets paths" { + const resp = handle(std.testing.allocator, "GET", "/api/nulltickets/tasks", "", .{ + .tickets_url = "http://127.0.0.1:7711", + }); + try std.testing.expectEqualStrings("404 Not Found", resp.status); + try std.testing.expectEqualStrings("{\"error\":\"not found\"}", resp.body); +} + +test "handle rejects unsupported methods before fetch" { + const resp = handle(std.testing.allocator, "HEAD", "/api/nulltickets/store/search", "", .{ + .tickets_url = "http://127.0.0.1:7711", + }); + try std.testing.expectEqualStrings("405 Method Not Allowed", resp.status); + try std.testing.expectEqualStrings("{\"error\":\"method not allowed\"}", resp.body); +} + +test "handle forwards store path, strips selector, and sends bearer token" { + if (comptime @import("builtin").os.tag == .windows) return error.SkipZigTest; + + const allocator = std.testing.allocator; + var upstream = try http_proxy.TestUpstream.start(allocator, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\n\r\n{\"ok\":true}"); + defer upstream.deinit(); + + const base_url = try upstream.baseUrl(allocator); + defer allocator.free(base_url); + + const resp = handle(allocator, "GET", "/api/nulltickets/store/search?q=tasks&tickets_instance=tracker-a&limit=10", "", .{ + .tickets_url = base_url, + .tickets_token = "tickets-token", + }); + defer allocator.free(resp.body); + + try std.testing.expectEqualStrings("200 OK", resp.status); + try std.testing.expectEqualStrings("{\"ok\":true}", resp.body); + try std.testing.expect(std.mem.indexOf(u8, upstream.request(), "GET /store/search?q=tasks&limit=10 HTTP/1.1") != null); + try std.testing.expect(std.mem.indexOf(u8, upstream.request(), "Authorization: Bearer tickets-token") != null); + try std.testing.expect(std.mem.indexOf(u8, upstream.request(), "tickets_instance") == null); +} diff --git a/src/api/nullwatch.zig b/src/api/nullwatch.zig new file mode 100644 index 0000000..720cfad --- /dev/null +++ b/src/api/nullwatch.zig @@ -0,0 +1,136 @@ +const std = @import("std"); +const http_proxy = @import("proxy.zig"); +const query = @import("query.zig"); + +const Allocator = std.mem.Allocator; + +const Response = http_proxy.Response; + +const prefix = "/api/nullwatch"; + +pub const Config = struct { + watch_url: ?[]const u8 = null, + watch_token: ?[]const u8 = null, +}; + +pub fn isProxyPath(target: []const u8) bool { + return http_proxy.isTargetInNamespace(target, prefix); +} + +pub fn selectedWatchNameAlloc(allocator: Allocator, target: []const u8) !?[]u8 { + return try query.valueAlloc(allocator, target, "nullhub_watch"); +} + +/// Proxies NullWatch API requests to a managed or configured NullWatch instance. +/// The shared `/api/nullwatch` prefix is stripped before forwarding, so +/// `/api/nullwatch/v1/runs` becomes `/v1/runs` on NullWatch. +pub fn handle(allocator: Allocator, method: []const u8, target: []const u8, body: []const u8, cfg: Config) Response { + if (!isProxyPath(target)) { + return .{ .status = "404 Not Found", .content_type = "application/json", .body = "{\"error\":\"not found\"}" }; + } + + const base_url = cfg.watch_url orelse + return .{ .status = "503 Service Unavailable", .content_type = "application/json", .body = "{\"error\":\"NullWatch not configured\"}" }; + + const selector_params = [_][]const u8{"nullhub_watch"}; + var forwarded = http_proxy.rewriteProductProxyTarget(allocator, target, .{ + .prefix = prefix, + .selector_params = selector_params[0..], + .default_path = "/v1/summary", + }) catch + return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" }; + defer forwarded.deinit(allocator); + + return http_proxy.forward(allocator, .{ + .method = method, + .base_url = base_url, + .path = forwarded.path, + .body = body, + .bearer_token = cfg.watch_token, + .unreachable_body = "{\"error\":\"NullWatch unreachable\"}", + }); +} + +test "isProxyPath matches NullWatch namespace" { + try std.testing.expect(isProxyPath("/api/nullwatch")); + try std.testing.expect(isProxyPath("/api/nullwatch?watch=default")); + try std.testing.expect(isProxyPath("/api/nullwatch/v1/runs")); + try std.testing.expect(isProxyPath("/api/nullwatch/health")); + try std.testing.expect(!isProxyPath("/api/nullboiler/v1/runs")); + try std.testing.expect(!isProxyPath("/api/nulltickets/store/runs")); +} + +test "handle returns not configured without NullWatch URL" { + const resp = handle(std.testing.allocator, "GET", "/api/nullwatch/v1/summary", "", .{}); + try std.testing.expectEqualStrings("503 Service Unavailable", resp.status); + try std.testing.expectEqualStrings("{\"error\":\"NullWatch not configured\"}", resp.body); +} + +test "handle rejects non-NullWatch paths" { + const resp = handle(std.testing.allocator, "GET", "/api/status", "", .{ + .watch_url = "http://127.0.0.1:7710", + }); + try std.testing.expectEqualStrings("404 Not Found", resp.status); +} + +test "selectedWatchNameAlloc reads hub selector query params" { + const allocator = std.testing.allocator; + const selected = (try selectedWatchNameAlloc(allocator, "/api/nullwatch/v1/runs?limit=1&nullhub_watch=watch+one")).?; + defer allocator.free(selected); + try std.testing.expectEqualStrings("watch one", selected); + try std.testing.expect((try selectedWatchNameAlloc(allocator, "/api/nullwatch/v1/runs?watch=upstream")) == null); +} + +test "rewriteProductProxyTarget removes only NullHub watch selector" { + const allocator = std.testing.allocator; + const selector_params = [_][]const u8{"nullhub_watch"}; + var stripped = try http_proxy.rewriteProductProxyTarget(allocator, "/api/nullwatch/v1/runs?limit=50&nullhub_watch=alpha&status=ok", .{ + .prefix = prefix, + .selector_params = selector_params[0..], + .default_path = "/v1/summary", + }); + defer stripped.deinit(allocator); + try std.testing.expectEqualStrings("/api/nullwatch/v1/runs?limit=50&status=ok", stripped.target); + try std.testing.expectEqualStrings("/v1/runs?limit=50&status=ok", stripped.path); + + var root = try http_proxy.rewriteProductProxyTarget(allocator, "/api/nullwatch?nullhub_watch=alpha", .{ + .prefix = prefix, + .selector_params = selector_params[0..], + .default_path = "/v1/summary", + }); + defer root.deinit(allocator); + try std.testing.expectEqualStrings("/api/nullwatch", root.target); + try std.testing.expectEqualStrings("/v1/summary", root.path); + + var upstream_filter = try http_proxy.rewriteProductProxyTarget(allocator, "/api/nullwatch/v1/runs?watch=alpha&instance=demo", .{ + .prefix = prefix, + .selector_params = selector_params[0..], + .default_path = "/v1/summary", + }); + defer upstream_filter.deinit(allocator); + try std.testing.expectEqualStrings("/api/nullwatch/v1/runs?watch=alpha&instance=demo", upstream_filter.target); + try std.testing.expectEqualStrings("/v1/runs?watch=alpha&instance=demo", upstream_filter.path); +} + +test "handle forwards NullWatch path, strips selector, and sends bearer token" { + if (comptime @import("builtin").os.tag == .windows) return error.SkipZigTest; + + const allocator = std.testing.allocator; + var upstream = try http_proxy.TestUpstream.start(allocator, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\n\r\n{\"ok\":true}"); + defer upstream.deinit(); + + const base_url = try upstream.baseUrl(allocator); + defer allocator.free(base_url); + + const resp = handle(allocator, "GET", "/api/nullwatch/v1/runs?limit=50&nullhub_watch=alpha&status=ok", "", .{ + .watch_url = base_url, + .watch_token = "watch-token", + }); + defer allocator.free(resp.body); + + try std.testing.expectEqualStrings("200 OK", resp.status); + try std.testing.expectEqualStrings("{\"ok\":true}", resp.body); + try std.testing.expect(std.mem.indexOf(u8, upstream.request(), "GET /v1/runs?limit=50&status=ok HTTP/1.1") != null); + try std.testing.expect(std.mem.indexOf(u8, upstream.request(), "Authorization: Bearer watch-token") != null); + try std.testing.expect(std.mem.indexOf(u8, upstream.request(), "nullhub_watch") == null); +} diff --git a/src/api/observability.zig b/src/api/observability.zig deleted file mode 100644 index bec5071..0000000 --- a/src/api/observability.zig +++ /dev/null @@ -1,120 +0,0 @@ -const std = @import("std"); -const http_proxy = @import("proxy.zig"); -const query = @import("query.zig"); - -const Allocator = std.mem.Allocator; - -const Response = http_proxy.Response; - -const prefix = "/api/observability"; - -pub const Config = struct { - watch_url: ?[]const u8 = null, - watch_token: ?[]const u8 = null, -}; - -pub fn isProxyPath(target: []const u8) bool { - return http_proxy.isPathInNamespace(target, prefix) or - (target.len > prefix.len and - std.mem.startsWith(u8, target, prefix) and - target[prefix.len] == '?'); -} - -pub fn selectedWatchNameAlloc(allocator: Allocator, target: []const u8) !?[]u8 { - return try query.valueAlloc(allocator, target, "nullhub_watch"); -} - -fn isSelectorParam(param: []const u8) bool { - const key = if (std.mem.indexOfScalar(u8, param, '=')) |idx| param[0..idx] else param; - return std.mem.eql(u8, key, "nullhub_watch"); -} - -fn stripSelectorParamsAlloc(allocator: Allocator, target: []const u8) ![]u8 { - const qmark = std.mem.indexOfScalar(u8, target, '?') orelse return allocator.dupe(u8, target); - - var buf = std.array_list.Managed(u8).init(allocator); - errdefer buf.deinit(); - try buf.appendSlice(target[0..qmark]); - - var wrote_query = false; - var params = std.mem.splitScalar(u8, target[qmark + 1 ..], '&'); - while (params.next()) |param| { - if (param.len == 0 or isSelectorParam(param)) continue; - try buf.append(if (wrote_query) '&' else '?'); - wrote_query = true; - try buf.appendSlice(param); - } - - return buf.toOwnedSlice(); -} - -/// Proxies observability API requests to a managed or configured NullWatch instance. -/// The shared `/api/observability` prefix is stripped before forwarding, so -/// `/api/observability/v1/runs` becomes `/v1/runs` on NullWatch. -pub fn handle(allocator: Allocator, method: []const u8, target: []const u8, body: []const u8, cfg: Config) Response { - if (!isProxyPath(target)) { - return .{ .status = "404 Not Found", .content_type = "application/json", .body = "{\"error\":\"not found\"}" }; - } - - const base_url = cfg.watch_url orelse - return .{ .status = "503 Service Unavailable", .content_type = "application/json", .body = "{\"error\":\"NullWatch not configured\"}" }; - - const forward_target = stripSelectorParamsAlloc(allocator, target) catch - return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" }; - defer allocator.free(forward_target); - - const proxied_path = forward_target[prefix.len..]; - const path = if (proxied_path.len == 0) "/v1/summary" else proxied_path; - return http_proxy.forward(allocator, .{ - .method = method, - .base_url = base_url, - .path = path, - .body = body, - .bearer_token = cfg.watch_token, - .unreachable_body = "{\"error\":\"NullWatch unreachable\"}", - }); -} - -test "isProxyPath matches observability namespace" { - try std.testing.expect(isProxyPath("/api/observability")); - try std.testing.expect(isProxyPath("/api/observability?watch=default")); - try std.testing.expect(isProxyPath("/api/observability/v1/runs")); - try std.testing.expect(isProxyPath("/api/observability/health")); - try std.testing.expect(!isProxyPath("/api/orchestration/v1/runs")); -} - -test "handle returns not configured without NullWatch URL" { - const resp = handle(std.testing.allocator, "GET", "/api/observability/v1/summary", "", .{}); - try std.testing.expectEqualStrings("503 Service Unavailable", resp.status); - try std.testing.expectEqualStrings("{\"error\":\"NullWatch not configured\"}", resp.body); -} - -test "handle rejects non-observability paths" { - const resp = handle(std.testing.allocator, "GET", "/api/status", "", .{ - .watch_url = "http://127.0.0.1:7710", - }); - try std.testing.expectEqualStrings("404 Not Found", resp.status); -} - -test "selectedWatchNameAlloc reads hub selector query params" { - const allocator = std.testing.allocator; - const selected = (try selectedWatchNameAlloc(allocator, "/api/observability/v1/runs?limit=1&nullhub_watch=watch+one")).?; - defer allocator.free(selected); - try std.testing.expectEqualStrings("watch one", selected); - try std.testing.expect((try selectedWatchNameAlloc(allocator, "/api/observability/v1/runs?watch=upstream")) == null); -} - -test "stripSelectorParamsAlloc removes only NullHub watch selector" { - const allocator = std.testing.allocator; - const stripped = try stripSelectorParamsAlloc(allocator, "/api/observability/v1/runs?limit=50&nullhub_watch=alpha&status=ok"); - defer allocator.free(stripped); - try std.testing.expectEqualStrings("/api/observability/v1/runs?limit=50&status=ok", stripped); - - const root = try stripSelectorParamsAlloc(allocator, "/api/observability?nullhub_watch=alpha"); - defer allocator.free(root); - try std.testing.expectEqualStrings("/api/observability", root); - - const upstream_filter = try stripSelectorParamsAlloc(allocator, "/api/observability/v1/runs?watch=alpha&instance=demo"); - defer allocator.free(upstream_filter); - try std.testing.expectEqualStrings("/api/observability/v1/runs?watch=alpha&instance=demo", upstream_filter); -} diff --git a/src/api/orchestration.zig b/src/api/orchestration.zig deleted file mode 100644 index d6a5684..0000000 --- a/src/api/orchestration.zig +++ /dev/null @@ -1,325 +0,0 @@ -const std = @import("std"); -const std_compat = @import("compat"); -const http_proxy = @import("proxy.zig"); -const Allocator = std.mem.Allocator; -const query_api = @import("query.zig"); - -const Response = http_proxy.Response; - -const prefix = "/api/orchestration"; -const store_prefix = "/api/orchestration/store"; - -pub const Config = struct { - boiler_url: ?[]const u8 = null, - boiler_token: ?[]const u8 = null, - tickets_url: ?[]const u8 = null, - tickets_token: ?[]const u8 = null, -}; - -const Backend = enum { - boiler, - tickets, - - fn notConfiguredBody(self: Backend) []const u8 { - return switch (self) { - .boiler => "{\"error\":\"NullBoiler not configured\"}", - .tickets => "{\"error\":\"NullTickets not configured\"}", - }; - } - - fn unreachableBody(self: Backend) []const u8 { - return switch (self) { - .boiler => "{\"error\":\"NullBoiler unreachable\"}", - .tickets => "{\"error\":\"NullTickets unreachable\"}", - }; - } -}; - -pub fn isProxyPath(target: []const u8) bool { - const clean = query_api.stripTarget(target); - return http_proxy.isPathInNamespace(clean, prefix); -} - -fn isStorePath(target: []const u8) bool { - const clean = query_api.stripTarget(target); - return std.mem.eql(u8, clean, store_prefix) or std.mem.startsWith(u8, clean, store_prefix ++ "/"); -} - -const ProxyTarget = struct { - backend: Backend, - base_url: []const u8, - token: ?[]const u8, -}; - -fn backendForPath(target: []const u8) ?Backend { - if (!isProxyPath(target)) return null; - return if (isStorePath(target)) .tickets else .boiler; -} - -pub fn requestedTicketsInstance(allocator: Allocator, target: []const u8) !?[]u8 { - if (!isStorePath(target)) return null; - const value = (try query_api.valueAlloc(allocator, target, "tickets_instance")) orelse return null; - if (value.len == 0) { - allocator.free(value); - return null; - } - return value; -} - -pub fn requestedBoilerInstance(allocator: Allocator, target: []const u8) !?[]u8 { - if (!isProxyPath(target) or isStorePath(target)) return null; - const value = (try query_api.valueAlloc(allocator, target, "boiler_instance")) orelse return null; - if (value.len == 0) { - allocator.free(value); - return null; - } - return value; -} - -fn resolveProxyTarget(target: []const u8, cfg: Config) ?ProxyTarget { - const backend = backendForPath(target) orelse return null; - return switch (backend) { - .tickets => blk: { - const base_url = cfg.tickets_url orelse return null; - break :blk .{ - .backend = .tickets, - .base_url = base_url, - .token = cfg.tickets_token, - }; - }, - .boiler => blk: { - const base_url = cfg.boiler_url orelse return null; - break :blk .{ - .backend = .boiler, - .base_url = base_url, - .token = cfg.boiler_token, - }; - }, - }; -} - -/// Proxies orchestration API requests to the local orchestration stack. -/// `/api/orchestration/store/*` goes to NullTickets; all other orchestration -/// routes go to NullBoiler. The shared prefix is stripped before forwarding. -pub fn handle(allocator: Allocator, method: []const u8, target: []const u8, body: []const u8, cfg: Config) Response { - if (!isProxyPath(target)) { - return .{ .status = "404 Not Found", .content_type = "application/json", .body = "{\"error\":\"not found\"}" }; - } - const backend = backendForPath(target) orelse - return .{ .status = "404 Not Found", .content_type = "application/json", .body = "{\"error\":\"not found\"}" }; - const resolved = resolveProxyTarget(target, cfg) orelse - return .{ .status = "503 Service Unavailable", .content_type = "application/json", .body = backend.notConfiguredBody() }; - - var forwarded = forwardedTarget(allocator, target) catch - return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" }; - defer forwarded.deinit(allocator); - - const proxied_path = forwarded.value[prefix.len..]; - const path = if (proxied_path.len == 0) "/" else proxied_path; - - return http_proxy.forward(allocator, .{ - .method = method, - .base_url = resolved.base_url, - .path = path, - .body = body, - .bearer_token = resolved.token, - .unreachable_body = resolved.backend.unreachableBody(), - }); -} - -const ForwardedTarget = struct { - value: []const u8, - owned: bool = false, - - fn deinit(self: *ForwardedTarget, allocator: Allocator) void { - if (self.owned) allocator.free(self.value); - self.* = .{ .value = "" }; - } -}; - -fn forwardedTarget(allocator: Allocator, target: []const u8) !ForwardedTarget { - const qmark = std.mem.indexOfScalar(u8, target, '?') orelse return .{ .value = target }; - var stripped_any = false; - var buf = std.array_list.Managed(u8).init(allocator); - errdefer buf.deinit(); - - try buf.appendSlice(target[0..qmark]); - var wrote_query = false; - var params = std.mem.splitScalar(u8, target[qmark + 1 ..], '&'); - while (params.next()) |param| { - if (isHubProxyParam(param)) { - stripped_any = true; - continue; - } - try buf.append(if (wrote_query) '&' else '?'); - wrote_query = true; - try buf.appendSlice(param); - } - - if (!stripped_any) { - buf.deinit(); - return .{ .value = target }; - } - return .{ .value = try buf.toOwnedSlice(), .owned = true }; -} - -fn isHubProxyParam(param: []const u8) bool { - const key = if (std.mem.indexOfScalar(u8, param, '=')) |eq| param[0..eq] else param; - return std.mem.eql(u8, key, "tickets_instance") or std.mem.eql(u8, key, "boiler_instance"); -} - -const TestUpstream = struct { - allocator: Allocator, - ctx: *Context, - thread: std.Thread, - - const Context = struct { - server: std_compat.net.Server, - stop_flag: std.atomic.Value(bool), - response: []u8, - - fn run(ctx: *Context) void { - while (!ctx.stop_flag.load(.acquire)) { - var conn = ctx.server.accept() catch |err| switch (err) { - error.WouldBlock => { - std_compat.thread.sleep(10 * std.time.ns_per_ms); - continue; - }, - else => return, - }; - defer conn.stream.close(); - - var read_buf: [1024]u8 = undefined; - _ = conn.stream.read(&read_buf) catch return; - _ = conn.stream.write(ctx.response) catch return; - return; - } - } - }; - - fn start(allocator: Allocator, response: []const u8) !TestUpstream { - const response_owned = try allocator.dupe(u8, response); - errdefer allocator.free(response_owned); - - const ctx = try allocator.create(Context); - errdefer allocator.destroy(ctx); - ctx.* = .{ - .server = undefined, - .stop_flag = std.atomic.Value(bool).init(false), - .response = response_owned, - }; - - const addr = try std_compat.net.Address.resolveIp("127.0.0.1", 0); - ctx.server = try addr.listen(.{}); - errdefer ctx.server.deinit(); - - const thread = try std.Thread.spawn(.{}, Context.run, .{ctx}); - - return .{ - .allocator = allocator, - .ctx = ctx, - .thread = thread, - }; - } - - fn deinit(self: *TestUpstream) void { - self.ctx.stop_flag.store(true, .release); - self.thread.join(); - self.ctx.server.deinit(); - self.allocator.free(self.ctx.response); - self.allocator.destroy(self.ctx); - } - - fn baseUrl(self: *const TestUpstream, allocator: Allocator) ![]const u8 { - return std.fmt.allocPrint(allocator, "http://127.0.0.1:{d}", .{self.ctx.server.listen_address.in.getPort()}); - } -}; - -test "isProxyPath matches orchestration namespace" { - try std.testing.expect(isProxyPath("/api/orchestration")); - try std.testing.expect(isProxyPath("/api/orchestration?tickets_instance=tracker-a")); - try std.testing.expect(isProxyPath("/api/orchestration/runs")); - try std.testing.expect(isProxyPath("/api/orchestration/store/search")); - try std.testing.expect(!isProxyPath("/api/instances")); -} - -test "backendForPath routes store requests to tickets backend" { - try std.testing.expectEqual(Backend.tickets, backendForPath("/api/orchestration/store/search?tickets_instance=tracker-a").?); - try std.testing.expectEqual(Backend.boiler, backendForPath("/api/orchestration/runs").?); -} - -test "requestedTicketsInstance decodes store target selection" { - const allocator = std.testing.allocator; - const value = (try requestedTicketsInstance(allocator, "/api/orchestration/store/ns?tickets_instance=tracker%20a")).?; - defer allocator.free(value); - try std.testing.expectEqualStrings("tracker a", value); - try std.testing.expect(try requestedTicketsInstance(allocator, "/api/orchestration/runs?tickets_instance=tracker-a") == null); -} - -test "requestedBoilerInstance decodes orchestration target selection" { - const allocator = std.testing.allocator; - const value = (try requestedBoilerInstance(allocator, "/api/orchestration/workflows?boiler_instance=boiler%20a")).?; - defer allocator.free(value); - try std.testing.expectEqualStrings("boiler a", value); - try std.testing.expect(try requestedBoilerInstance(allocator, "/api/orchestration/store/ns?boiler_instance=boiler-a") == null); -} - -test "forwardedTarget strips hub-only proxy params" { - const allocator = std.testing.allocator; - var forwarded = try forwardedTarget(allocator, "/api/orchestration/store/search?q=tasks&tickets_instance=tracker-a&limit=10"); - defer forwarded.deinit(allocator); - try std.testing.expectEqualStrings("/api/orchestration/store/search?q=tasks&limit=10", forwarded.value); - - var boiler_forwarded = try forwardedTarget(allocator, "/api/orchestration/runs?boiler_instance=boiler-a&status=running"); - defer boiler_forwarded.deinit(allocator); - try std.testing.expectEqualStrings("/api/orchestration/runs?status=running", boiler_forwarded.value); -} - -test "handle routes store paths to NullTickets config" { - const resp = handle(std.testing.allocator, "GET", "/api/orchestration/store/search", "", .{ - .boiler_url = "http://127.0.0.1:8080", - }); - try std.testing.expectEqualStrings("503 Service Unavailable", resp.status); - try std.testing.expectEqualStrings("{\"error\":\"NullTickets not configured\"}", resp.body); -} - -test "handle routes non-store paths to NullBoiler config" { - const resp = handle(std.testing.allocator, "GET", "/api/orchestration/runs", "", .{ - .tickets_url = "http://127.0.0.1:7711", - }); - try std.testing.expectEqualStrings("503 Service Unavailable", resp.status); - try std.testing.expectEqualStrings("{\"error\":\"NullBoiler not configured\"}", resp.body); -} - -test "handle returns 404 for non-orchestration paths" { - const resp = handle(std.testing.allocator, "GET", "/api/status", "", .{}); - try std.testing.expectEqualStrings("404 Not Found", resp.status); - try std.testing.expectEqualStrings("{\"error\":\"not found\"}", resp.body); -} - -test "handle rejects unsupported methods before fetch" { - const resp = handle(std.testing.allocator, "HEAD", "/api/orchestration/runs", "", .{ - .boiler_url = "http://127.0.0.1:8080", - }); - try std.testing.expectEqualStrings("405 Method Not Allowed", resp.status); - try std.testing.expectEqualStrings("{\"error\":\"method not allowed\"}", resp.body); -} - -test "handle passes through upstream 409 status and body" { - if (comptime @import("builtin").os.tag == .windows) return error.SkipZigTest; - - const allocator = std.testing.allocator; - var upstream = try TestUpstream.start(allocator, "HTTP/1.1 409 Conflict\r\nContent-Type: application/json\r\nContent-Length: 20\r\n\r\n{\"error\":\"conflict\"}"); - defer upstream.deinit(); - - const base_url = try upstream.baseUrl(allocator); - defer allocator.free(base_url); - - const resp = handle(allocator, "GET", "/api/orchestration/runs", "", .{ - .boiler_url = base_url, - }); - defer allocator.free(resp.body); - - try std.testing.expectEqualStrings("409 Conflict", resp.status); - try std.testing.expectEqualStrings("{\"error\":\"conflict\"}", resp.body); -} diff --git a/src/api/providers.zig b/src/api/providers.zig index 2f398e7..015ef1a 100644 --- a/src/api/providers.zig +++ b/src/api/providers.zig @@ -1026,8 +1026,7 @@ test "handleCreate without base_url requires nullclaw instance" { test "handleValidate for custom provider uses models probe (not nullclaw)" { // Regression: handleValidate for a custom provider must not require a nullclaw // instance — it uses the /models probe directly. The probe will fail here - // (no server at 19999) but the key point is we get a live_ok + reason response, - // NOT the old "custom endpoint — validation via /models not yet available" placeholder. + // (no server at 19999) but the key point is we get a live_ok + reason response. const allocator = std.testing.allocator; var fixture = try test_helpers.TempPaths.init(allocator); defer fixture.deinit(); @@ -1046,9 +1045,8 @@ test "handleValidate for custom provider uses models probe (not nullclaw)" { const json = try handleValidate(allocator, 1, &s, fixture.paths); defer allocator.free(json); - // Must return a probe result (live_ok present), never the old placeholder string. + // Must return a probe result. try std.testing.expect(std.mem.indexOf(u8, json, "\"live_ok\"") != null); - try std.testing.expect(std.mem.indexOf(u8, json, "not yet available") == null); // No nullclaw probe: no "Install a nullclaw instance" error expected. try std.testing.expect(std.mem.indexOf(u8, json, "Install a nullclaw instance") == null); // Probe should fail (19999 is not running in tests) diff --git a/src/api/proxy.zig b/src/api/proxy.zig index 7d9d11b..94ae2a6 100644 --- a/src/api/proxy.zig +++ b/src/api/proxy.zig @@ -1,5 +1,6 @@ const std = @import("std"); const std_compat = @import("compat"); +const net_compat = @import("../net_compat.zig"); const Allocator = std.mem.Allocator; @@ -15,7 +16,71 @@ pub const ForwardOptions = struct { path: []const u8, body: []const u8, bearer_token: ?[]const u8 = null, + content_type: []const u8 = "application/json", + accept: ?[]const u8 = null, unreachable_body: []const u8 = "{\"error\":\"upstream unreachable\"}", + max_response_bytes: ?usize = null, +}; + +const LimitedResponseBody = struct { + body: std.Io.Writer.Allocating, + writer: std.Io.Writer, + limit: usize, + written: usize = 0, + too_large: bool = false, + + fn init(allocator: Allocator, max_response_bytes: ?usize) LimitedResponseBody { + return .{ + .body = .init(allocator), + .writer = .{ + .vtable = &vtable, + .buffer = &.{}, + }, + .limit = max_response_bytes orelse std.math.maxInt(usize), + }; + } + + fn deinit(self: *LimitedResponseBody) void { + self.body.deinit(); + } + + fn toOwnedSlice(self: *LimitedResponseBody) Allocator.Error![]u8 { + return try self.body.toOwnedSlice(); + } + + const vtable: std.Io.Writer.VTable = .{ + .drain = drain, + .flush = flush, + }; + + fn drain(writer: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize { + const self: *LimitedResponseBody = @fieldParentPtr("writer", writer); + if (data.len == 0) return 0; + const total = std.Io.Writer.countSplat(data, splat); + const next_written = std.math.add(usize, self.written, total) catch { + self.too_large = true; + return error.WriteFailed; + }; + if (next_written > self.limit) { + self.too_large = true; + return error.WriteFailed; + } + + for (data[0 .. data.len - 1]) |bytes| { + self.body.writer.writeAll(bytes) catch return error.WriteFailed; + } + const pattern = data[data.len - 1]; + for (0..splat) |_| { + self.body.writer.writeAll(pattern) catch return error.WriteFailed; + } + self.written = next_written; + return total; + } + + fn flush(writer: *std.Io.Writer) std.Io.Writer.Error!void { + const self: *LimitedResponseBody = @fieldParentPtr("writer", writer); + try self.body.writer.flush(); + } }; pub fn isPathInNamespace(target: []const u8, prefix: []const u8) bool { @@ -25,6 +90,106 @@ pub fn isPathInNamespace(target: []const u8, prefix: []const u8) bool { target[prefix.len] == '/'); } +pub fn isTargetInNamespace(target: []const u8, prefix: []const u8) bool { + const path = if (std.mem.indexOfScalar(u8, target, '?')) |idx| target[0..idx] else target; + return isPathInNamespace(path, prefix); +} + +pub const ProductProxyRewriteOptions = struct { + prefix: []const u8, + selector_params: []const []const u8 = &.{}, + default_path: []const u8 = "/", +}; + +pub const ProductProxyTarget = struct { + target: []const u8, + path: []const u8, + target_owned: bool = false, + path_owned: bool = false, + + pub fn deinit(self: *ProductProxyTarget, allocator: Allocator) void { + if (self.path_owned) allocator.free(self.path); + if (self.target_owned) allocator.free(self.target); + self.* = .{ .target = "", .path = "" }; + } +}; + +pub fn rewriteProductProxyTarget(allocator: Allocator, target: []const u8, opts: ProductProxyRewriteOptions) !ProductProxyTarget { + var stripped = try stripSelectorParams(allocator, target, opts.selector_params); + errdefer stripped.deinit(allocator); + + const suffix = stripped.value[opts.prefix.len..]; + if (suffix.len == 0) { + return .{ + .target = stripped.value, + .path = opts.default_path, + .target_owned = stripped.owned, + }; + } + + if (suffix[0] == '?') { + return .{ + .target = stripped.value, + .path = try std.fmt.allocPrint(allocator, "{s}{s}", .{ opts.default_path, suffix }), + .target_owned = stripped.owned, + .path_owned = true, + }; + } + + return .{ + .target = stripped.value, + .path = suffix, + .target_owned = stripped.owned, + }; +} + +const StrippedTarget = struct { + value: []const u8, + owned: bool = false, + + fn deinit(self: *StrippedTarget, allocator: Allocator) void { + if (self.owned) allocator.free(self.value); + self.* = .{ .value = "" }; + } +}; + +fn stripSelectorParams(allocator: Allocator, target: []const u8, selector_params: []const []const u8) !StrippedTarget { + const qmark = std.mem.indexOfScalar(u8, target, '?') orelse return .{ .value = target }; + if (selector_params.len == 0) return .{ .value = target }; + + var stripped_any = false; + var buf = std.array_list.Managed(u8).init(allocator); + errdefer buf.deinit(); + + try buf.appendSlice(target[0..qmark]); + var wrote_query = false; + var params = std.mem.splitScalar(u8, target[qmark + 1 ..], '&'); + while (params.next()) |param| { + if (param.len == 0) continue; + if (isSelectorParam(param, selector_params)) { + stripped_any = true; + continue; + } + try buf.append(if (wrote_query) '&' else '?'); + wrote_query = true; + try buf.appendSlice(param); + } + + if (!stripped_any) { + buf.deinit(); + return .{ .value = target }; + } + return .{ .value = try buf.toOwnedSlice(), .owned = true }; +} + +fn isSelectorParam(param: []const u8, selector_params: []const []const u8) bool { + const key = if (std.mem.indexOfScalar(u8, param, '=')) |eq| param[0..eq] else param; + for (selector_params) |selector| { + if (std.mem.eql(u8, key, selector)) return true; + } + return false; +} + pub fn forward(allocator: Allocator, opts: ForwardOptions) Response { const http_method = parseMethod(opts.method) orelse return .{ .status = "405 Method Not Allowed", .content_type = "application/json", .body = "{\"error\":\"method not allowed\"}" }; @@ -35,18 +200,28 @@ pub fn forward(allocator: Allocator, opts: ForwardOptions) Response { var auth_header: ?[]const u8 = null; defer if (auth_header) |value| allocator.free(value); - var header_buf: [1]std.http.Header = undefined; + var header_buf: [3]std.http.Header = undefined; + var header_count: usize = 0; + if (opts.body.len > 0 and opts.content_type.len > 0) { + header_buf[header_count] = .{ .name = "Content-Type", .value = opts.content_type }; + header_count += 1; + } + if (opts.accept) |accept| { + header_buf[header_count] = .{ .name = "Accept", .value = accept }; + header_count += 1; + } const extra_headers: []const std.http.Header = if (opts.bearer_token) |token| blk: { auth_header = std.fmt.allocPrint(allocator, "Bearer {s}", .{token}) catch return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" }; - header_buf[0] = .{ .name = "Authorization", .value = auth_header.? }; - break :blk header_buf[0..1]; - } else &.{}; + header_buf[header_count] = .{ .name = "Authorization", .value = auth_header.? }; + header_count += 1; + break :blk header_buf[0..header_count]; + } else header_buf[0..header_count]; var client: std.http.Client = .{ .allocator = allocator, .io = std_compat.io() }; defer client.deinit(); - var response_body: std.Io.Writer.Allocating = .init(allocator); + var response_body = LimitedResponseBody.init(allocator, opts.max_response_bytes); defer response_body.deinit(); const result = client.fetch(.{ @@ -55,8 +230,12 @@ pub fn forward(allocator: Allocator, opts: ForwardOptions) Response { .payload = if (opts.body.len > 0) opts.body else null, .response_writer = &response_body.writer, .extra_headers = extra_headers, - }) catch { - return .{ .status = "502 Bad Gateway", .content_type = "application/json", .body = opts.unreachable_body }; + }) catch |err| switch (err) { + error.WriteFailed => if (response_body.too_large) + return .{ .status = "502 Bad Gateway", .content_type = "application/json", .body = "{\"error\":\"upstream response too large\"}" } + else + return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" }, + else => return .{ .status = "502 Bad Gateway", .content_type = "application/json", .body = opts.unreachable_body }, }; const resp_body = response_body.toOwnedSlice() catch @@ -64,11 +243,209 @@ pub fn forward(allocator: Allocator, opts: ForwardOptions) Response { return .{ .status = mapStatus(@intFromEnum(result.status)), - .content_type = "application/json", + .content_type = if (@intFromEnum(result.status) >= 200 and @intFromEnum(result.status) < 300) (opts.accept orelse "application/json") else "application/json", .body = resp_body, }; } +pub const TestUpstream = struct { + allocator: Allocator, + ctx: *Context, + thread: std.Thread, + + const Context = struct { + server: std_compat.net.Server, + stop_flag: std.atomic.Value(bool), + response: []u8, + request_buf: [4096]u8 = undefined, + request_len: usize = 0, + + fn run(ctx: *Context) void { + while (!ctx.stop_flag.load(.acquire)) { + var conn = ctx.server.accept() catch |err| switch (err) { + error.WouldBlock => { + std_compat.thread.sleep(10 * std.time.ns_per_ms); + continue; + }, + else => return, + }; + defer conn.stream.close(); + + ctx.request_len = conn.stream.read(&ctx.request_buf) catch return; + _ = conn.stream.write(ctx.response) catch return; + return; + } + } + }; + + pub fn start(allocator: Allocator, response: []const u8) !TestUpstream { + const response_owned = try allocator.dupe(u8, response); + errdefer allocator.free(response_owned); + + const ctx = try allocator.create(Context); + errdefer allocator.destroy(ctx); + ctx.* = .{ + .server = undefined, + .stop_flag = std.atomic.Value(bool).init(false), + .response = response_owned, + }; + + const addr = try std_compat.net.Address.resolveIp("127.0.0.1", 0); + ctx.server = try addr.listen(.{}); + errdefer ctx.server.deinit(); + + const thread = try std.Thread.spawn(.{}, Context.run, .{ctx}); + + return .{ + .allocator = allocator, + .ctx = ctx, + .thread = thread, + }; + } + + pub fn deinit(self: *TestUpstream) void { + self.ctx.stop_flag.store(true, .release); + self.thread.join(); + self.ctx.server.deinit(); + self.allocator.free(self.ctx.response); + self.allocator.destroy(self.ctx); + } + + pub fn baseUrl(self: *const TestUpstream, allocator: Allocator) ![]const u8 { + return std.fmt.allocPrint(allocator, "http://127.0.0.1:{d}", .{self.ctx.server.listen_address.in.getPort()}); + } + + pub fn request(self: *const TestUpstream) []const u8 { + return self.ctx.request_buf[0..self.ctx.request_len]; + } +}; + +pub fn forwardStream(allocator: Allocator, opts: ForwardOptions, downstream: std_compat.net.Stream, cors_headers: []const u8) !void { + const http_method = parseMethod(opts.method) orelse { + try writeDirectResponse(downstream, "405 Method Not Allowed", "application/json", "{\"error\":\"method not allowed\"}", cors_headers); + return; + }; + const url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ opts.base_url, opts.path }); + defer allocator.free(url); + const uri = std.Uri.parse(url) catch { + try writeDirectResponse(downstream, "502 Bad Gateway", "application/json", opts.unreachable_body, cors_headers); + return; + }; + + var auth_header: ?[]const u8 = null; + defer if (auth_header) |value| allocator.free(value); + var header_buf: [3]std.http.Header = undefined; + var header_count: usize = 0; + if (opts.body.len > 0 and opts.content_type.len > 0) { + header_buf[header_count] = .{ .name = "Content-Type", .value = opts.content_type }; + header_count += 1; + } + if (opts.accept) |accept| { + header_buf[header_count] = .{ .name = "Accept", .value = accept }; + header_count += 1; + } + const extra_headers: []const std.http.Header = if (opts.bearer_token) |token| blk: { + auth_header = try std.fmt.allocPrint(allocator, "Bearer {s}", .{token}); + header_buf[header_count] = .{ .name = "Authorization", .value = auth_header.? }; + header_count += 1; + break :blk header_buf[0..header_count]; + } else header_buf[0..header_count]; + + var client: std.http.Client = .{ .allocator = allocator, .io = std_compat.io() }; + defer client.deinit(); + + var request = client.request(http_method, uri, .{ + .redirect_behavior = .unhandled, + .keep_alive = false, + .headers = .{ + .accept_encoding = .omit, + .connection = .{ .override = "close" }, + }, + .extra_headers = extra_headers, + }) catch { + try writeDirectResponse(downstream, "502 Bad Gateway", "application/json", opts.unreachable_body, cors_headers); + return; + }; + defer request.deinit(); + + if (http_method.requestHasBody()) { + request.transfer_encoding = .{ .content_length = opts.body.len }; + var body_buffer: [8192]u8 = undefined; + var body_writer = request.sendBodyUnflushed(&body_buffer) catch { + try writeDirectResponse(downstream, "502 Bad Gateway", "application/json", opts.unreachable_body, cors_headers); + return; + }; + body_writer.writer.writeAll(opts.body) catch { + try writeDirectResponse(downstream, "502 Bad Gateway", "application/json", opts.unreachable_body, cors_headers); + return; + }; + body_writer.end() catch { + try writeDirectResponse(downstream, "502 Bad Gateway", "application/json", opts.unreachable_body, cors_headers); + return; + }; + request.connection.?.flush() catch { + try writeDirectResponse(downstream, "502 Bad Gateway", "application/json", opts.unreachable_body, cors_headers); + return; + }; + } else { + request.sendBodiless() catch { + try writeDirectResponse(downstream, "502 Bad Gateway", "application/json", opts.unreachable_body, cors_headers); + return; + }; + } + + var response = request.receiveHead(&.{}) catch { + try writeDirectResponse(downstream, "502 Bad Gateway", "application/json", opts.unreachable_body, cors_headers); + return; + }; + const status_code = @intFromEnum(response.head.status); + const content_type = response.head.content_type orelse + if (status_code >= 200 and status_code < 300) (opts.accept orelse "application/octet-stream") else "application/json"; + + try writeStreamingResponseHeaders(downstream, mapStatus(status_code), content_type, cors_headers); + + var transfer_buffer: [64]u8 = undefined; + const reader = response.reader(&transfer_buffer); + var read_buf: [16 * 1024]u8 = undefined; + while (true) { + const n = reader.readSliceShort(&read_buf) catch |err| { + std.log.warn("upstream stream read failed: {s}", .{@errorName(err)}); + return; + }; + if (n == 0) return; + try net_compat.streamWriteAll(downstream, read_buf[0..n]); + } +} + +fn writeDirectResponse(stream: std_compat.net.Stream, status: []const u8, content_type: []const u8, body: []const u8, cors_headers: []const u8) !void { + var buf: [4096]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try writer.print("HTTP/1.1 {s}\r\n", .{status}); + try writer.print("Content-Type: {s}\r\n", .{content_type}); + try writer.print("Content-Length: {d}\r\n", .{body.len}); + try writer.writeAll(cors_headers); + try writer.writeAll("Connection: close\r\n\r\n"); + if (body.len <= buf.len - writer.buffered().len) { + try writer.writeAll(body); + try net_compat.streamWriteAll(stream, writer.buffered()); + return; + } + try net_compat.streamWriteAll(stream, writer.buffered()); + if (body.len > 0) try net_compat.streamWriteAll(stream, body); +} + +fn writeStreamingResponseHeaders(stream: std_compat.net.Stream, status: []const u8, content_type: []const u8, cors_headers: []const u8) !void { + var buf: [4096]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try writer.print("HTTP/1.1 {s}\r\n", .{status}); + try writer.print("Content-Type: {s}\r\n", .{content_type}); + try writer.writeAll("Cache-Control: no-cache\r\n"); + try writer.writeAll("X-Accel-Buffering: no\r\n"); + try writer.writeAll(cors_headers); + try writer.writeAll("Connection: close\r\n\r\n"); + try net_compat.streamWriteAll(stream, writer.buffered()); +} + fn parseMethod(method: []const u8) ?std.http.Method { if (std.mem.eql(u8, method, "GET")) return .GET; if (std.mem.eql(u8, method, "POST")) return .POST; @@ -80,29 +457,146 @@ fn parseMethod(method: []const u8) ?std.http.Method { fn mapStatus(code: u10) []const u8 { return switch (code) { + 100 => "100 Continue", + 101 => "101 Switching Protocols", + 102 => "102 Processing", + 103 => "103 Early Hints", 200 => "200 OK", 201 => "201 Created", + 202 => "202 Accepted", + 203 => "203 Non-Authoritative Information", 204 => "204 No Content", + 205 => "205 Reset Content", + 206 => "206 Partial Content", + 207 => "207 Multi-Status", + 208 => "208 Already Reported", + 226 => "226 IM Used", + 300 => "300 Multiple Choices", + 301 => "301 Moved Permanently", + 302 => "302 Found", + 303 => "303 See Other", + 304 => "304 Not Modified", + 305 => "305 Use Proxy", + 307 => "307 Temporary Redirect", + 308 => "308 Permanent Redirect", 400 => "400 Bad Request", 401 => "401 Unauthorized", + 402 => "402 Payment Required", 403 => "403 Forbidden", 404 => "404 Not Found", 405 => "405 Method Not Allowed", + 406 => "406 Not Acceptable", + 407 => "407 Proxy Authentication Required", + 408 => "408 Request Timeout", 409 => "409 Conflict", + 410 => "410 Gone", + 411 => "411 Length Required", + 412 => "412 Precondition Failed", + 413 => "413 Payload Too Large", + 414 => "414 URI Too Long", 415 => "415 Unsupported Media Type", + 416 => "416 Range Not Satisfiable", + 417 => "417 Expectation Failed", + 418 => "418 I'm a Teapot", + 421 => "421 Misdirected Request", 422 => "422 Unprocessable Entity", + 423 => "423 Locked", + 424 => "424 Failed Dependency", + 425 => "425 Too Early", + 426 => "426 Upgrade Required", + 428 => "428 Precondition Required", + 429 => "429 Too Many Requests", + 431 => "431 Request Header Fields Too Large", + 451 => "451 Unavailable For Legal Reasons", 500 => "500 Internal Server Error", + 501 => "501 Not Implemented", 502 => "502 Bad Gateway", 503 => "503 Service Unavailable", + 504 => "504 Gateway Timeout", + 505 => "505 HTTP Version Not Supported", + 506 => "506 Variant Also Negotiates", + 507 => "507 Insufficient Storage", + 508 => "508 Loop Detected", + 510 => "510 Not Extended", + 511 => "511 Network Authentication Required", else => if (code >= 200 and code < 300) "200 OK" else if (code >= 400 and code < 500) "400 Bad Request" else "500 Internal Server Error", }; } test "isPathInNamespace matches exact and slash-delimited paths" { - try std.testing.expect(isPathInNamespace("/api/observability", "/api/observability")); - try std.testing.expect(isPathInNamespace("/api/observability/v1/runs", "/api/observability")); - try std.testing.expect(isPathInNamespace("/api/observability/v1/runs?limit=1", "/api/observability")); - try std.testing.expect(!isPathInNamespace("/api/observability?limit=1", "/api/observability")); - try std.testing.expect(!isPathInNamespace("/api/observability-extra", "/api/observability")); - try std.testing.expect(!isPathInNamespace("/api/orchestration", "/api/observability")); + try std.testing.expect(isPathInNamespace("/api/nullwatch", "/api/nullwatch")); + try std.testing.expect(isPathInNamespace("/api/nullwatch/v1/runs", "/api/nullwatch")); + try std.testing.expect(isPathInNamespace("/api/nullwatch/v1/runs?limit=1", "/api/nullwatch")); + try std.testing.expect(!isPathInNamespace("/api/nullwatch?limit=1", "/api/nullwatch")); + try std.testing.expect(!isPathInNamespace("/api/nullwatch-extra", "/api/nullwatch")); + try std.testing.expect(!isPathInNamespace("/api/nullboiler", "/api/nullwatch")); +} + +test "isTargetInNamespace matches query targets by path only" { + try std.testing.expect(isTargetInNamespace("/api/nullwatch?limit=1", "/api/nullwatch")); + try std.testing.expect(isTargetInNamespace("/api/nullwatch/v1/runs?limit=1", "/api/nullwatch")); + try std.testing.expect(!isTargetInNamespace("/api/nullwatch-extra?limit=1", "/api/nullwatch")); +} + +test "rewriteProductProxyTarget strips selector params and public prefix" { + const allocator = std.testing.allocator; + const selector_params = [_][]const u8{"nullhub_watch"}; + + var rewritten = try rewriteProductProxyTarget( + allocator, + "/api/nullwatch/v1/runs?limit=50&nullhub_watch=alpha&status=ok", + .{ + .prefix = "/api/nullwatch", + .selector_params = selector_params[0..], + .default_path = "/v1/summary", + }, + ); + defer rewritten.deinit(allocator); + + try std.testing.expectEqualStrings("/api/nullwatch/v1/runs?limit=50&status=ok", rewritten.target); + try std.testing.expectEqualStrings("/v1/runs?limit=50&status=ok", rewritten.path); +} + +test "rewriteProductProxyTarget keeps similarly named upstream params" { + const allocator = std.testing.allocator; + const selector_params = [_][]const u8{"boiler_instance"}; + + var rewritten = try rewriteProductProxyTarget( + allocator, + "/api/nullboiler/runs?boiler_instance_id=upstream&boiler_instance=local&status=running", + .{ + .prefix = "/api/nullboiler", + .selector_params = selector_params[0..], + }, + ); + defer rewritten.deinit(allocator); + + try std.testing.expectEqualStrings("/api/nullboiler/runs?boiler_instance_id=upstream&status=running", rewritten.target); + try std.testing.expectEqualStrings("/runs?boiler_instance_id=upstream&status=running", rewritten.path); +} + +test "rewriteProductProxyTarget keeps root upstream filters on default path" { + const allocator = std.testing.allocator; + const selector_params = [_][]const u8{"nullhub_watch"}; + + var rewritten = try rewriteProductProxyTarget( + allocator, + "/api/nullwatch?nullhub_watch=alpha&watch=upstream", + .{ + .prefix = "/api/nullwatch", + .selector_params = selector_params[0..], + .default_path = "/v1/summary", + }, + ); + defer rewritten.deinit(allocator); + + try std.testing.expectEqualStrings("/api/nullwatch?watch=upstream", rewritten.target); + try std.testing.expectEqualStrings("/v1/summary?watch=upstream", rewritten.path); +} + +test "mapStatus preserves common upstream status codes" { + try std.testing.expectEqualStrings("202 Accepted", mapStatus(202)); + try std.testing.expectEqualStrings("206 Partial Content", mapStatus(206)); + try std.testing.expectEqualStrings("429 Too Many Requests", mapStatus(429)); + try std.testing.expectEqualStrings("504 Gateway Timeout", mapStatus(504)); } diff --git a/src/api/updates.zig b/src/api/updates.zig index 8b73fbf..5aab03d 100644 --- a/src/api/updates.zig +++ b/src/api/updates.zig @@ -57,17 +57,6 @@ fn versionsEqual(a: []const u8, b: []const u8) bool { return std.mem.eql(u8, stripV(a), stripV(b)); } -fn normalizedLaunchModeForUpdate(component: []const u8, launch_mode: []const u8, known: registry.KnownComponent) []const u8 { - const normalized = registry.normalizeLaunchCommand(component, launch_mode); - if (!std.mem.eql(u8, known.default_launch_command, "gateway") and std.mem.eql(u8, normalized, "gateway")) { - return known.default_launch_command; - } - if (std.mem.eql(u8, component, "nullwatch") and std.mem.eql(u8, normalized, "nullwatch")) { - return known.default_launch_command; - } - return normalized; -} - fn fetchLatestTagForComponent(allocator: std.mem.Allocator, component: []const u8) ?[]u8 { if (builtin.is_test) return null; @@ -127,8 +116,8 @@ fn buildUpdatesJson(allocator: std.mem.Allocator, buf: *std.array_list.Managed(u try buf.appendSlice("]}"); } -/// Compatibility wrapper used by tests and internal call sites that don't have -/// supervisor context yet. +/// Lightweight update response helper for tests and call sites without +/// supervisor runtime context. pub fn handleApplyUpdate(allocator: std.mem.Allocator, s: *state_mod.State, component: []const u8, name: []const u8) ApiResponse { _ = s.getInstance(component, name) orelse return notFound(); @@ -208,7 +197,7 @@ pub fn handleApplyUpdateRuntime( const inst_dir = paths.instanceDir(allocator, component, name) catch return serverError(); defer allocator.free(inst_dir); - const launch_mode = normalizedLaunchModeForUpdate(component, entry.launch_mode, known); + const launch_mode = entry.launch_mode; var launch = launch_args_mod.resolve(allocator, launch_mode, entry.verbose) catch return serverError(); defer launch.deinit(); const effective_port = launch.effectiveHealthPort(port); @@ -406,13 +395,6 @@ test "handleApplyUpdate returns success for existing instance" { try std.testing.expectEqualStrings("Update initiated", parsed.value.message); } -test "normalizedLaunchModeForUpdate maps legacy nullwatch launch modes to serve" { - const known = registry.findKnownComponent("nullwatch").?; - try std.testing.expectEqualStrings("serve", normalizedLaunchModeForUpdate("nullwatch", "gateway", known)); - try std.testing.expectEqualStrings("serve", normalizedLaunchModeForUpdate("nullwatch", "nullwatch", known)); - try std.testing.expectEqualStrings("serve", normalizedLaunchModeForUpdate("nullwatch", "serve", known)); -} - test "parseUpdatePath extracts component and name correctly" { const p = parseUpdatePath("/api/instances/nullclaw/my-agent/update").?; try std.testing.expectEqualStrings("nullclaw", p.component); diff --git a/src/bundled_skills/nullhub-admin/SKILL.md b/src/bundled_skills/nullhub-admin/SKILL.md index a52ee88..c007ced 100644 --- a/src/bundled_skills/nullhub-admin/SKILL.md +++ b/src/bundled_skills/nullhub-admin/SKILL.md @@ -1,7 +1,7 @@ --- name: nullhub-admin version: 0.1.0 -description: Teach managed nullclaw agents to discover NullHub routes first and then use nullhub api for instance, provider, component, and orchestration tasks. +description: Teach managed nullclaw agents to discover NullHub routes first and then use nullhub api for instance, provider, component, and NullBoiler workflow tasks. always: true requires_bins: - nullhub @@ -9,7 +9,7 @@ requires_bins: # NullHub Admin -Use this skill whenever the task involves `nullhub`, NullHub-managed instances, providers, components, or orchestration routes. +Use this skill whenever the task involves `nullhub`, NullHub-managed instances, providers, components, or NullBoiler workflow routes. Workflow: diff --git a/src/cli.zig b/src/cli.zig index 765a851..28eba44 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -83,10 +83,6 @@ pub const UninstallOptions = struct { remove_data: bool = false, }; -pub const AddSourceOptions = struct { - repo: []const u8, -}; - pub const ReportRepo = report_schema.ReportRepo; pub const ReportType = report_schema.ReportType; @@ -120,7 +116,6 @@ pub const Command = union(enum) { api: ApiOptions, service: ServiceCommand, uninstall: UninstallOptions, - add_source: AddSourceOptions, report: ReportOptions, help, }; @@ -200,9 +195,6 @@ pub fn parse(allocator: std.mem.Allocator, args: *ArgIterator) Command { if (std.mem.eql(u8, cmd, "uninstall")) { return parseUninstall(args); } - if (std.mem.eql(u8, cmd, "add-source")) { - return parseAddSource(args); - } if (std.mem.eql(u8, cmd, "report")) { return parseReport(args); } @@ -454,12 +446,6 @@ fn parseUninstall(args: *ArgIterator) Command { return .{ .uninstall = opts }; } -fn parseAddSource(args: *ArgIterator) Command { - const repo = args.next() orelse return .help; - if (repo.len == 0 or repo[0] == '-') return .help; - return .{ .add_source = .{ .repo = repo } }; -} - fn parseReport(args: *ArgIterator) Command { var opts = ReportOptions{}; while (args.next()) |arg| { @@ -518,7 +504,6 @@ pub fn printUsage() void { \\ api Call any local nullhub HTTP API route \\ uninstall Remove an instance \\ service Manage OS service - \\ add-source Add custom component source \\ report Report a bug or feature request \\ version, -v, --version Show version \\ diff --git a/src/compat/fs.zig b/src/compat/fs.zig index f43d419..bf6bff6 100644 --- a/src/compat/fs.zig +++ b/src/compat/fs.zig @@ -240,6 +240,14 @@ pub const Dir = struct { self.toInner().close(shared.io()); } + pub fn sync(self: Dir) std.Io.File.SyncError!void { + const file: std.Io.File = .{ + .handle = self.handle, + .flags = .{ .nonblocking = false }, + }; + try file.sync(shared.io()); + } + pub fn iterate(self: Dir) Iterator { return .{ .inner = self.toInner().iterate() }; } diff --git a/src/core/durable_file.zig b/src/core/durable_file.zig new file mode 100644 index 0000000..c8ccf50 --- /dev/null +++ b/src/core/durable_file.zig @@ -0,0 +1,40 @@ +const std = @import("std"); +const std_compat = @import("compat"); + +pub fn writeTextFileAtomically(allocator: std.mem.Allocator, path: []const u8, contents: []const u8) !void { + try writeTextFileAtomicallyWithMode(allocator, path, contents, null); +} + +pub fn writeTextFileAtomicallyWithMode(allocator: std.mem.Allocator, path: []const u8, contents: []const u8, mode: ?std_compat.fs.File.Mode) !void { + const dir_path = std.fs.path.dirname(path) orelse return error.InvalidPath; + const base_name = std.fs.path.basename(path); + const tmp_name = try std.fmt.allocPrint(allocator, ".{s}.{x}.tmp", .{ + base_name, + std_compat.crypto.random.int(u64), + }); + defer allocator.free(tmp_name); + + const tmp_path = try std.fs.path.join(allocator, &.{ dir_path, tmp_name }); + defer allocator.free(tmp_path); + errdefer std_compat.fs.deleteFileAbsolute(tmp_path) catch {}; + + { + const file = try std_compat.fs.createFileAbsolute(tmp_path, .{ .truncate = true }); + defer file.close(); + if (mode) |file_mode| { + if (comptime std_compat.fs.has_executable_bit) try file.chmod(file_mode); + } + try file.writeAll(contents); + try file.writeAll("\n"); + try file.sync(); + } + + try std_compat.fs.renameAbsolute(tmp_path, path); + try syncDirectory(dir_path); +} + +pub fn syncDirectory(path: []const u8) !void { + var dir = try std_compat.fs.openDirAbsolute(path, .{}); + defer dir.close(); + try dir.sync(); +} diff --git a/src/core/integration.zig b/src/core/integration.zig index cd1c2b7..3bbb329 100644 --- a/src/core/integration.zig +++ b/src/core/integration.zig @@ -1,6 +1,7 @@ const std = @import("std"); const std_compat = @import("compat"); const access = @import("../access.zig"); +const durable_file = @import("durable_file.zig"); const paths_mod = @import("paths.zig"); const state_mod = @import("state.zig"); const test_helpers = @import("../test_helpers.zig"); @@ -26,7 +27,6 @@ pub const NullBoilerWorkflowConfig = struct { }; pub const managed_workflow_file_name = "nullhub-tracker-workflow.json"; -pub const legacy_workflow_file_name = "tracker-workflow.json"; pub const default_tracker_prompt_template = "Task {{task.id}}: {{task.title}}\n\n{{task.description}}\n\nMetadata:\n{{task.metadata}}"; @@ -436,7 +436,6 @@ pub fn linkNullBoilerToNullTickets( ensureNullBoilerTrackerWorkflowFile( allocator, - config_path, workflows_dir, options.pipeline_id, options.claim_role, @@ -614,7 +613,6 @@ fn ensurePath(path: []const u8) !void { fn ensureNullBoilerTrackerWorkflowFile( allocator: std.mem.Allocator, - config_path: []const u8, workflows_dir: []const u8, pipeline_id: []const u8, claim_role: []const u8, @@ -643,33 +641,9 @@ fn ensureNullBoilerTrackerWorkflowFile( }); defer allocator.free(rendered); - try writeTextFileAtomically(allocator, workflow_path, rendered); + try durable_file.writeTextFileAtomically(allocator, workflow_path, rendered); deleteStaleNullHubManagedWorkflows(allocator, workflows_dir) catch {}; - - const config_dir = std.fs.path.dirname(config_path) orelse return error.InvalidPath; - const legacy_path = try std.fs.path.join(allocator, &.{ config_dir, legacy_workflow_file_name }); - defer allocator.free(legacy_path); - std_compat.fs.deleteFileAbsolute(legacy_path) catch {}; - - const legacy_workflows_path = try std.fs.path.join(allocator, &.{ workflows_dir, legacy_workflow_file_name }); - defer allocator.free(legacy_workflows_path); - std_compat.fs.deleteFileAbsolute(legacy_workflows_path) catch {}; -} - -fn writeTextFileAtomically(allocator: std.mem.Allocator, path: []const u8, contents: []const u8) !void { - const tmp_path = try std.fmt.allocPrint(allocator, "{s}.tmp", .{path}); - defer allocator.free(tmp_path); - errdefer std_compat.fs.deleteFileAbsolute(tmp_path) catch {}; - - { - const file_out = try std_compat.fs.createFileAbsolute(tmp_path, .{ .truncate = true }); - defer file_out.close(); - try file_out.writeAll(contents); - try file_out.writeAll("\n"); - } - - try std_compat.fs.renameAbsolute(tmp_path, path); } fn deleteStaleNullHubManagedWorkflows(allocator: std.mem.Allocator, workflows_dir: []const u8) !void { diff --git a/src/core/mission_control.zig b/src/core/mission_control.zig new file mode 100644 index 0000000..6ef2473 --- /dev/null +++ b/src/core/mission_control.zig @@ -0,0 +1,930 @@ +const std = @import("std"); +const replay = @import("mission_control_replay.zig"); + +pub const RuntimeState = struct { + launched: bool = false, + started_at_ms: i64 = 0, + recovered: bool = false, + recovery_started_at_ms: i64 = 0, +}; + +pub const MissionControls = struct { + can_launch: bool, + can_recover: bool, + can_reset: bool, +}; + +pub const Agent = struct { + id: []const u8, + role: []const u8, + status: []const u8, + current_step: []const u8, +}; + +pub const GraphNode = struct { + id: []const u8, + label: []const u8, + kind: []const u8, + status: []const u8, +}; + +pub const GraphEdge = struct { + from: []const u8, + to: []const u8, + status: []const u8, +}; + +pub const MissionGraph = struct { + nodes: []const GraphNode, + edges: []const GraphEdge, +}; + +pub const MissionEvent = struct { + at_ms: i64, + source: []const u8, + level: []const u8, + title: []const u8, + detail: []const u8, + status: []const u8, + trace: ?replay.EventTraceDef, +}; + +pub const MissionTelemetry = struct { + runs: usize, + spans: usize, + evals: usize, + errors: usize, + total_tokens: usize, + total_cost_usd: f64, + verdict: []const u8, +}; + +pub const WorkflowEvidenceRefs = struct { + scenario_id: []const u8, + mission_id: []const u8, + failed_run_id: []const u8, + recovered_run_id: []const u8, + checkpoint_id: []const u8, +}; + +pub const WorkflowEvidenceRun = struct { + run_id: []const u8, + status: []const u8, + created_at_ms: ?i64 = null, + updated_at_ms: ?i64 = null, + checkpoint_count: ?usize = null, +}; + +pub const WorkflowEvidenceCheckpoint = struct { + id: []const u8, + run_id: []const u8, + step_id: []const u8, + parent_id: ?[]const u8 = null, + version: ?i64 = null, + created_at_ms: ?i64 = null, + completed_nodes: []const []const u8 = &.{}, + metadata: ?std.json.Value = null, +}; + +pub const WorkflowEvidence = struct { + status: []const u8, + source: []const u8 = "nullboiler", + boiler_instance: ?[]const u8 = null, + failed_run: ?WorkflowEvidenceRun = null, + recovered_run: ?WorkflowEvidenceRun = null, + checkpoint: ?WorkflowEvidenceCheckpoint = null, + scanned_run_count: usize = 0, + reason: ?[]const u8 = null, +}; + +pub const FailurePanel = struct { + run_id: []const u8, + checkpoint_id: []const u8, + failed_step: []const u8, + error_message: []const u8, + suggested_intervention: []const u8, +}; + +pub const RecoveryPanel = struct { + run_id: []const u8, + forked_from: []const u8, + human_instruction: []const u8, + status: []const u8, +}; + +pub const ReplayArtifactPanel = struct { + artifact_kind: []const u8, + artifact_role: []const u8, + run_id: []const u8, + workflow_run_id: ?[]const u8, + workflow_status: ?[]const u8, + phase: []const u8, + status: []const u8, + headline: []const u8, + verdict: []const u8, + trace_id: ?[]const u8, + checkpoint_id: ?[]const u8, + checkpoint_step: ?[]const u8, + forked_from: ?[]const u8, + human_instruction: ?[]const u8, + failure_message: ?[]const u8, + telemetry: MissionTelemetry, +}; + +pub const ReplayArtifactDelta = struct { + verdict_changed: bool, + checkpoint_reused: bool, + spans_delta: i64, + evals_delta: i64, + errors_delta: i64, + tokens_delta: i64, + cost_delta_usd: f64, +}; + +pub const ReplayArtifactComparison = struct { + failed: ReplayArtifactPanel, + recovered: ReplayArtifactPanel, + delta: ReplayArtifactDelta, +}; + +pub const MissionSnapshot = struct { + schema_version: u8, + mode: []const u8, + scenario_id: []const u8, + scenario_version: []const u8, + generated_at_ms: i64, + mission_id: []const u8, + title: []const u8, + status: []const u8, + phase: []const u8, + headline: []const u8, + elapsed_ms: i64, + progress: u8, + active_run_id: ?[]const u8, + failed_run_id: ?[]const u8, + recovered_run_id: ?[]const u8, + controls: MissionControls, + agents: []const Agent, + graph: MissionGraph, + events: []const MissionEvent, + telemetry: MissionTelemetry, + workflow_evidence: WorkflowEvidence, + replay_comparison: ?ReplayArtifactComparison, + failure: ?FailurePanel, + recovery: ?RecoveryPanel, +}; + +pub const ComponentMapping = struct { + component: []const u8, + role: []const u8, + evidence: []const []const u8, +}; + +pub const WorkflowMapping = struct { + component: []const u8, + role: []const u8, + status: []const u8, + source: []const u8, + boiler_instance: ?[]const u8, + checkpoint_id: []const u8, + failed_run_id: []const u8, + recovered_run_id: []const u8, + human_instruction: []const u8, + evidence: []const []const u8, +}; + +pub const ObservabilityMapping = struct { + component: []const u8, + role: []const u8, + failed_run_id: []const u8, + recovered_run_id: []const u8, + trace_ref_source: []const u8, + evidence: []const []const u8, +}; + +pub const ReplayArtifactMapping = struct { + nulltickets: ComponentMapping, + nullboiler: WorkflowMapping, + nullclaw: ComponentMapping, + nullwatch: ObservabilityMapping, +}; + +pub const ReplayArtifact = struct { + artifact_schema_version: u8, + artifact_kind: []const u8, + generated_at_ms: i64, + replay_fixture_path: []const u8, + scenario_id: []const u8, + scenario_version: []const u8, + mode: []const u8, + snapshot: MissionSnapshot, + replay_fixture: replay.ReplayFixture, + workflow_evidence: WorkflowEvidence, + ecosystem_mapping: ReplayArtifactMapping, +}; + +pub const SnapshotView = struct { + parsed: std.json.Parsed(replay.ReplayFixture), + agents: []Agent, + nodes: []GraphNode, + edges: []GraphEdge, + events: []MissionEvent, + snapshot: MissionSnapshot, + + pub fn deinit(self: *SnapshotView, allocator: std.mem.Allocator) void { + allocator.free(self.events); + allocator.free(self.edges); + allocator.free(self.nodes); + allocator.free(self.agents); + self.parsed.deinit(); + self.* = undefined; + } +}; + +pub const ReplayArtifactView = struct { + snapshot_view: SnapshotView, + artifact: ReplayArtifact, + + pub fn deinit(self: *ReplayArtifactView, allocator: std.mem.Allocator) void { + self.snapshot_view.deinit(allocator); + self.* = undefined; + } +}; + +pub fn reset(runtime: *RuntimeState) void { + runtime.* = .{}; +} + +pub fn canLaunch(runtime: RuntimeState) bool { + return !runtime.launched; +} + +pub fn launch(runtime: *RuntimeState, now_ms: i64) bool { + if (!canLaunch(runtime.*)) return false; + runtime.* = .{ + .launched = true, + .started_at_ms = now_ms, + }; + return true; +} + +pub fn parseReplay(allocator: std.mem.Allocator) !std.json.Parsed(replay.ReplayFixture) { + return replay.parseValidated(allocator); +} + +pub fn canRecoverAt(allocator: std.mem.Allocator, runtime: RuntimeState, now_ms: i64) !bool { + var parsed = try parseReplay(allocator); + defer parsed.deinit(); + + return canRecoverWithFixture(parsed.value, runtime, now_ms); +} + +pub fn recover(allocator: std.mem.Allocator, runtime: *RuntimeState, now_ms: i64) !bool { + var parsed = try parseReplay(allocator); + defer parsed.deinit(); + + return recoverWithFixture(parsed.value, runtime, now_ms); +} + +pub fn recoverWithFixture(fixture: replay.ReplayFixture, runtime: *RuntimeState, now_ms: i64) bool { + if (!canRecoverWithFixture(fixture, runtime.*, now_ms)) return false; + runtime.recovered = true; + runtime.recovery_started_at_ms = now_ms; + return true; +} + +pub fn canRecoverWithFixture(fixture: replay.ReplayFixture, runtime: RuntimeState, now_ms: i64) bool { + const elapsed_ms = elapsedSince(runtime.started_at_ms, now_ms); + const recovery_elapsed_ms = elapsedSince(runtime.recovery_started_at_ms, now_ms); + const phase = currentPhase(fixture, runtime, elapsed_ms, recovery_elapsed_ms); + return canRecoverPhase(fixture, runtime, phase); +} + +pub fn workflowEvidenceRefs(fixture: replay.ReplayFixture) WorkflowEvidenceRefs { + return .{ + .scenario_id = fixture.scenario_id, + .mission_id = fixture.scenario_id, + .failed_run_id = fixture.run_ids.failed, + .recovered_run_id = fixture.run_ids.recovered, + .checkpoint_id = fixture.checkpoint_id, + }; +} + +pub fn workflowEvidenceUnavailable(reason: []const u8) WorkflowEvidence { + return .{ + .status = "unavailable", + .reason = reason, + }; +} + +/// Returns an allocator-owned copy of workflow evidence for request/serialization lifetimes. +pub fn cloneWorkflowEvidence(allocator: std.mem.Allocator, evidence: WorkflowEvidence) !WorkflowEvidence { + return .{ + .status = try allocator.dupe(u8, evidence.status), + .source = try allocator.dupe(u8, evidence.source), + .boiler_instance = try cloneOptionalString(allocator, evidence.boiler_instance), + .failed_run = if (evidence.failed_run) |run| try cloneWorkflowEvidenceRun(allocator, run) else null, + .recovered_run = if (evidence.recovered_run) |run| try cloneWorkflowEvidenceRun(allocator, run) else null, + .checkpoint = if (evidence.checkpoint) |checkpoint| try cloneWorkflowEvidenceCheckpoint(allocator, checkpoint) else null, + .scanned_run_count = evidence.scanned_run_count, + .reason = try cloneOptionalString(allocator, evidence.reason), + }; +} + +pub fn cloneJsonValue(allocator: std.mem.Allocator, value: std.json.Value) !std.json.Value { + const rendered = try std.json.Stringify.valueAlloc(allocator, value, .{}); + defer allocator.free(rendered); + return try std.json.parseFromSliceLeaky(std.json.Value, allocator, rendered, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); +} + +fn cloneWorkflowEvidenceRun(allocator: std.mem.Allocator, run: WorkflowEvidenceRun) !WorkflowEvidenceRun { + return .{ + .run_id = try allocator.dupe(u8, run.run_id), + .status = try allocator.dupe(u8, run.status), + .created_at_ms = run.created_at_ms, + .updated_at_ms = run.updated_at_ms, + .checkpoint_count = run.checkpoint_count, + }; +} + +fn cloneWorkflowEvidenceCheckpoint(allocator: std.mem.Allocator, checkpoint: WorkflowEvidenceCheckpoint) !WorkflowEvidenceCheckpoint { + return .{ + .id = try allocator.dupe(u8, checkpoint.id), + .run_id = try allocator.dupe(u8, checkpoint.run_id), + .step_id = try allocator.dupe(u8, checkpoint.step_id), + .parent_id = try cloneOptionalString(allocator, checkpoint.parent_id), + .version = checkpoint.version, + .created_at_ms = checkpoint.created_at_ms, + .completed_nodes = try cloneStringSlice(allocator, checkpoint.completed_nodes), + .metadata = if (checkpoint.metadata) |metadata| try cloneJsonValue(allocator, metadata) else null, + }; +} + +fn cloneOptionalString(allocator: std.mem.Allocator, value: ?[]const u8) !?[]const u8 { + if (value) |text| return try allocator.dupe(u8, text); + return null; +} + +fn cloneStringSlice(allocator: std.mem.Allocator, values: []const []const u8) ![]const []const u8 { + if (values.len == 0) return &.{}; + const out = try allocator.alloc([]const u8, values.len); + for (values, 0..) |value, i| { + out[i] = try allocator.dupe(u8, value); + } + return out; +} + +pub fn buildSnapshotViewWithEvidence(allocator: std.mem.Allocator, runtime: RuntimeState, now_ms: i64, workflow_evidence: WorkflowEvidence) !SnapshotView { + const parsed = try parseReplay(allocator); + return buildSnapshotViewFromParsed(allocator, parsed, runtime, now_ms, workflow_evidence); +} + +pub fn buildSnapshotViewFromParsed( + allocator: std.mem.Allocator, + parsed_fixture: std.json.Parsed(replay.ReplayFixture), + runtime: RuntimeState, + now_ms: i64, + workflow_evidence: WorkflowEvidence, +) !SnapshotView { + var parsed = parsed_fixture; + errdefer parsed.deinit(); + const fixture = parsed.value; + + const elapsed_ms = elapsedSince(runtime.started_at_ms, now_ms); + const recovery_elapsed_ms = elapsedSince(runtime.recovery_started_at_ms, now_ms); + const phase = currentPhase(fixture, runtime, elapsed_ms, recovery_elapsed_ms); + const agents = try buildAgents(allocator, fixture, phase); + errdefer allocator.free(agents); + const nodes = try buildNodes(allocator, fixture, phase); + errdefer allocator.free(nodes); + const edges = try buildEdges(allocator, fixture, phase); + errdefer allocator.free(edges); + const events = try buildEvents(allocator, fixture, phase); + errdefer allocator.free(events); + const snapshot = buildSnapshot( + fixture, + runtime, + now_ms, + elapsed_ms, + phase, + agents, + nodes, + edges, + events, + workflow_evidence, + ); + return .{ + .parsed = parsed, + .agents = agents, + .nodes = nodes, + .edges = edges, + .events = events, + .snapshot = snapshot, + }; +} + +pub fn buildReplayArtifactViewWithEvidence(allocator: std.mem.Allocator, runtime: RuntimeState, now_ms: i64, workflow_evidence: WorkflowEvidence) !ReplayArtifactView { + const parsed = try parseReplay(allocator); + return buildReplayArtifactViewFromParsed(allocator, parsed, runtime, now_ms, workflow_evidence); +} + +pub fn buildReplayArtifactViewFromParsed( + allocator: std.mem.Allocator, + parsed_fixture: std.json.Parsed(replay.ReplayFixture), + runtime: RuntimeState, + now_ms: i64, + workflow_evidence: WorkflowEvidence, +) !ReplayArtifactView { + var snapshot_view = try buildSnapshotViewFromParsed(allocator, parsed_fixture, runtime, now_ms, workflow_evidence); + errdefer snapshot_view.deinit(allocator); + const fixture = snapshot_view.parsed.value; + const artifact = ReplayArtifact{ + .artifact_schema_version = 1, + .artifact_kind = "nullhub.mission_control.replay", + .generated_at_ms = now_ms, + .replay_fixture_path = "src/core/mission_control/code_red.v1.json", + .scenario_id = fixture.scenario_id, + .scenario_version = fixture.scenario_version, + .mode = fixture.mode, + .snapshot = snapshot_view.snapshot, + .replay_fixture = fixture, + .workflow_evidence = workflow_evidence, + .ecosystem_mapping = replayArtifactMapping(fixture, workflow_evidence), + }; + return .{ + .snapshot_view = snapshot_view, + .artifact = artifact, + }; +} + +fn replayArtifactMapping(fixture: replay.ReplayFixture, workflow_evidence: WorkflowEvidence) ReplayArtifactMapping { + const workflow_checkpoint_id = if (workflow_evidence.checkpoint) |checkpoint| checkpoint.id else fixture.checkpoint_id; + const workflow_failed_run_id = if (workflow_evidence.failed_run) |run| run.run_id else fixture.run_ids.failed; + const workflow_recovered_run_id = if (workflow_evidence.recovered_run) |run| run.run_id else fixture.run_ids.recovered; + + return .{ + .nulltickets = .{ + .component = "nulltickets", + .role = "Tracker-style task source and terminal workflow status.", + .evidence = &.{ "events[source=nulltickets]", "graph.nodes[kind=tracker]" }, + }, + .nullboiler = .{ + .component = "nullboiler", + .role = "Workflow execution, checkpointing, dispatch, and fork recovery.", + .status = workflow_evidence.status, + .source = workflow_evidence.source, + .boiler_instance = workflow_evidence.boiler_instance, + .checkpoint_id = workflow_checkpoint_id, + .failed_run_id = workflow_failed_run_id, + .recovered_run_id = workflow_recovered_run_id, + .human_instruction = fixture.human_instruction, + .evidence = &.{ "workflow_evidence", "phases", "graph.edges", "events[source=nullboiler]", "failure.checkpoint_id", "recovery.forked_from" }, + }, + .nullclaw = .{ + .component = "nullclaw", + .role = "Lightweight role agents that perform research, coding, testing, and review steps.", + .evidence = &.{ "agents", "events[source=nullclaw]", "graph.nodes[kind=agent]" }, + }, + .nullwatch = .{ + .component = "nullwatch", + .role = "Run, span, eval, token, cost, and failure telemetry references.", + .failed_run_id = fixture.run_ids.failed, + .recovered_run_id = fixture.run_ids.recovered, + .trace_ref_source = "events[].trace", + .evidence = &.{ "events[].trace", "telemetry", "failure.run_id", "recovery.run_id" }, + }, + }; +} + +fn buildSnapshot( + fixture: replay.ReplayFixture, + runtime: RuntimeState, + now_ms: i64, + elapsed_ms: i64, + phase: []const u8, + agents: []const Agent, + nodes: []const GraphNode, + edges: []const GraphEdge, + events: []const MissionEvent, + workflow_evidence: WorkflowEvidence, +) MissionSnapshot { + const failed_visible = isAtOrAfter(fixture, phase, fixture.failure.visible_from_phase); + const recovered_visible = runtime.recovered; + const recovered_artifact_visible = recovered_visible and std.mem.eql(u8, phase, "completed"); + const phase_def = replay.phaseById(fixture, phase).?; + + return .{ + .schema_version = fixture.schema_version, + .mode = fixture.mode, + .scenario_id = fixture.scenario_id, + .scenario_version = fixture.scenario_version, + .generated_at_ms = now_ms, + .mission_id = fixture.scenario_id, + .title = fixture.title, + .status = phase_def.status, + .phase = phase, + .headline = phase_def.headline, + .elapsed_ms = if (runtime.launched) elapsed_ms else 0, + .progress = phase_def.progress, + .active_run_id = activeRunId(fixture, phase), + .failed_run_id = if (failed_visible) fixture.failure.run_id else null, + .recovered_run_id = if (recovered_visible) fixture.recovery.run_id else null, + .controls = .{ + .can_launch = canLaunch(runtime), + .can_recover = canRecover(fixture, runtime, phase), + .can_reset = true, + }, + .agents = agents, + .graph = .{ + .nodes = nodes, + .edges = edges, + }, + .events = events, + .telemetry = telemetryForPhase(fixture, phase), + .workflow_evidence = workflow_evidence, + .replay_comparison = if (failed_visible and recovered_artifact_visible) buildReplayArtifactComparison(fixture, workflow_evidence) else null, + .failure = if (failed_visible) FailurePanel{ + .run_id = fixture.failure.run_id, + .checkpoint_id = fixture.failure.checkpoint_id, + .failed_step = fixture.failure.failed_step, + .error_message = fixture.failure.error_message, + .suggested_intervention = fixture.failure.suggested_intervention, + } else null, + .recovery = if (recovered_visible) RecoveryPanel{ + .run_id = fixture.recovery.run_id, + .forked_from = fixture.recovery.forked_from, + .human_instruction = fixture.recovery.human_instruction, + .status = if (std.mem.eql(u8, phase, "completed")) "passed" else "replaying", + } else null, + }; +} + +fn buildReplayArtifactComparison(fixture: replay.ReplayFixture, workflow_evidence: WorkflowEvidence) ReplayArtifactComparison { + const failed = replayArtifactPanel(fixture, workflow_evidence, "failed"); + const recovered = replayArtifactPanel(fixture, workflow_evidence, "recovered"); + return .{ + .failed = failed, + .recovered = recovered, + .delta = .{ + .verdict_changed = !std.mem.eql(u8, failed.verdict, recovered.verdict), + .checkpoint_reused = std.mem.eql(u8, fixture.failure.checkpoint_id, fixture.recovery.forked_from), + .spans_delta = signedDelta(recovered.telemetry.spans, failed.telemetry.spans), + .evals_delta = signedDelta(recovered.telemetry.evals, failed.telemetry.evals), + .errors_delta = signedDelta(recovered.telemetry.errors, failed.telemetry.errors), + .tokens_delta = signedDelta(recovered.telemetry.total_tokens, failed.telemetry.total_tokens), + .cost_delta_usd = recovered.telemetry.total_cost_usd - failed.telemetry.total_cost_usd, + }, + }; +} + +fn replayArtifactPanel(fixture: replay.ReplayFixture, workflow_evidence: WorkflowEvidence, role: []const u8) ReplayArtifactPanel { + const is_failed = std.mem.eql(u8, role, "failed"); + const run_id = if (is_failed) fixture.failure.run_id else fixture.recovery.run_id; + const phase = if (is_failed) "failed" else "completed"; + const phase_def = replay.phaseById(fixture, phase).?; + const telemetry = telemetryForPhase(fixture, phase); + const workflow_run = if (is_failed) workflow_evidence.failed_run else workflow_evidence.recovered_run; + const checkpoint = workflow_evidence.checkpoint; + + return .{ + .artifact_kind = "nullhub.mission_control.run_replay", + .artifact_role = role, + .run_id = run_id, + .workflow_run_id = if (workflow_run) |run| run.run_id else null, + .workflow_status = if (workflow_run) |run| run.status else null, + .phase = phase, + .status = phase_def.status, + .headline = phase_def.headline, + .verdict = telemetry.verdict, + .trace_id = traceIdForRun(fixture, run_id), + .checkpoint_id = if (is_failed) (if (checkpoint) |value| value.id else fixture.failure.checkpoint_id) else null, + .checkpoint_step = if (is_failed) (if (checkpoint) |value| value.step_id else fixture.failure.failed_step) else null, + .forked_from = if (is_failed) null else fixture.recovery.forked_from, + .human_instruction = if (is_failed) null else fixture.recovery.human_instruction, + .failure_message = if (is_failed) fixture.failure.error_message else null, + .telemetry = telemetry, + }; +} + +fn traceIdForRun(fixture: replay.ReplayFixture, run_id: []const u8) ?[]const u8 { + var found: ?[]const u8 = null; + for (fixture.events) |event| { + const trace = event.trace orelse continue; + const trace_run_id = trace.run_id orelse continue; + if (!std.mem.eql(u8, trace_run_id, run_id)) continue; + if (trace.trace_id) |trace_id| found = trace_id; + } + return found; +} + +fn signedDelta(after: usize, before: usize) i64 { + return @as(i64, @intCast(after)) - @as(i64, @intCast(before)); +} + +fn elapsedSince(start_ms: i64, now_ms: i64) i64 { + if (start_ms <= 0 or now_ms <= start_ms) return 0; + return now_ms - start_ms; +} + +fn currentPhase(fixture: replay.ReplayFixture, runtime: RuntimeState, elapsed_ms: i64, recovery_elapsed_ms: i64) []const u8 { + if (!runtime.launched) return "idle"; + return phaseForTrack(fixture, if (runtime.recovered) "recovery" else "primary", if (runtime.recovered) recovery_elapsed_ms else elapsed_ms); +} + +fn phaseForTrack(fixture: replay.ReplayFixture, track: []const u8, elapsed_ms: i64) []const u8 { + var selected: ?replay.PhaseDef = null; + for (fixture.phases) |phase| { + if (!std.mem.eql(u8, phase.track, track)) continue; + if (phase.starts_at_ms > elapsed_ms) continue; + if (selected == null or phase.starts_at_ms >= selected.?.starts_at_ms) { + selected = phase; + } + } + return if (selected) |phase| phase.id else "idle"; +} + +fn canRecover(fixture: replay.ReplayFixture, runtime: RuntimeState, phase: []const u8) bool { + return canRecoverPhase(fixture, runtime, phase); +} + +fn canRecoverPhase(fixture: replay.ReplayFixture, runtime: RuntimeState, phase: []const u8) bool { + return runtime.launched and !runtime.recovered and isAtOrAfter(fixture, phase, fixture.failure.visible_from_phase); +} + +fn activeRunId(fixture: replay.ReplayFixture, phase: []const u8) ?[]const u8 { + if (std.mem.eql(u8, phase, "idle")) return null; + if (isAtOrAfter(fixture, phase, fixture.recovery.visible_from_phase)) return fixture.recovery.run_id; + return fixture.failure.run_id; +} + +fn statusAfter(fixture: replay.ReplayFixture, phase: []const u8, own_phase: []const u8) []const u8 { + const current_rank = phaseRank(fixture, phase); + const own_rank = phaseRank(fixture, own_phase); + if (current_rank > own_rank) return "done"; + if (current_rank == own_rank) return "active"; + return "pending"; +} + +fn buildAgents(allocator: std.mem.Allocator, fixture: replay.ReplayFixture, phase: []const u8) ![]Agent { + const agents = try allocator.alloc(Agent, fixture.agents.len); + for (fixture.agents, 0..) |agent, index| { + agents[index] = .{ + .id = agent.id, + .role = agent.role, + .status = agentStatus(fixture, agent, phase), + .current_step = agentStep(agent, phase), + }; + } + return agents; +} + +fn agentStatus(fixture: replay.ReplayFixture, agent: replay.AgentDef, phase: []const u8) []const u8 { + for (agent.active_phases) |active_phase| { + if (std.mem.eql(u8, phase, active_phase)) return "active"; + } + if (agent.failed_phase) |failed_phase| { + if (std.mem.eql(u8, phase, failed_phase)) return "failed"; + } + if (agent.blocked_phase) |blocked_phase| { + if (std.mem.eql(u8, phase, blocked_phase)) return "blocked"; + } + if (phaseRank(fixture, phase) > phaseRank(fixture, agent.done_after_phase)) return "done"; + return "standby"; +} + +fn agentStep(agent: replay.AgentDef, phase: []const u8) []const u8 { + for (agent.steps) |step| { + if (std.mem.eql(u8, step.phase, phase)) return step.step; + } + return "waiting"; +} + +fn buildNodes(allocator: std.mem.Allocator, fixture: replay.ReplayFixture, phase: []const u8) ![]GraphNode { + const nodes = try allocator.alloc(GraphNode, fixture.graph.nodes.len); + for (fixture.graph.nodes, 0..) |node, index| { + nodes[index] = .{ + .id = node.id, + .label = node.label, + .kind = node.kind, + .status = nodeStatus(fixture, node, phase), + }; + } + return nodes; +} + +fn nodeStatus(fixture: replay.ReplayFixture, node: replay.GraphNodeDef, phase: []const u8) []const u8 { + if (node.error_phase) |error_phase| { + if (std.mem.eql(u8, phase, error_phase)) return "error"; + } + return statusAfter(fixture, phase, node.phase); +} + +fn buildEdges(allocator: std.mem.Allocator, fixture: replay.ReplayFixture, phase: []const u8) ![]GraphEdge { + const edges = try allocator.alloc(GraphEdge, fixture.graph.edges.len); + for (fixture.graph.edges, 0..) |edge, index| { + edges[index] = .{ + .from = edge.from, + .to = edge.to, + .status = edgeStatus(fixture, edge, phase), + }; + } + return edges; +} + +fn edgeStatus(fixture: replay.ReplayFixture, edge: replay.GraphEdgeDef, phase: []const u8) []const u8 { + if (edge.error_phase) |error_phase| { + if (std.mem.eql(u8, phase, error_phase)) return "error"; + } + return statusAfter(fixture, phase, edge.phase); +} + +fn buildEvents(allocator: std.mem.Allocator, fixture: replay.ReplayFixture, phase: []const u8) ![]MissionEvent { + const events = try allocator.alloc(MissionEvent, fixture.events.len); + for (fixture.events, 0..) |event, index| { + events[index] = .{ + .at_ms = event.at_ms, + .source = event.source, + .level = event.level, + .title = event.title, + .detail = event.detail, + .status = statusAfter(fixture, phase, event.phase), + .trace = event.trace, + }; + } + return events; +} + +fn telemetryForPhase(fixture: replay.ReplayFixture, phase: []const u8) MissionTelemetry { + const current_rank = phaseRank(fixture, phase); + var selected = fixture.telemetry[0]; + var selected_rank = phaseRank(fixture, selected.phase); + for (fixture.telemetry) |entry| { + const entry_rank = phaseRank(fixture, entry.phase); + if (entry_rank <= current_rank and entry_rank >= selected_rank) { + selected = entry; + selected_rank = entry_rank; + } + } + return .{ + .runs = selected.runs, + .spans = selected.spans, + .evals = selected.evals, + .errors = selected.errors, + .total_tokens = selected.total_tokens, + .total_cost_usd = selected.total_cost_usd, + .verdict = selected.verdict, + }; +} + +fn phaseRank(fixture: replay.ReplayFixture, phase: []const u8) u8 { + return replay.phaseRank(fixture, phase) orelse 0; +} + +fn isAtOrAfter(fixture: replay.ReplayFixture, phase: []const u8, threshold: []const u8) bool { + return phaseRank(fixture, phase) >= phaseRank(fixture, threshold); +} + +fn snapshotJsonForTest(allocator: std.mem.Allocator, runtime: RuntimeState, now_ms: i64) ![]u8 { + var view = try buildSnapshotViewWithEvidence(allocator, runtime, now_ms, workflowEvidenceForTest()); + defer view.deinit(allocator); + + return std.json.Stringify.valueAlloc(allocator, view.snapshot, .{ .whitespace = .indent_2 }); +} + +fn replayArtifactJsonForTest(allocator: std.mem.Allocator, runtime: RuntimeState, now_ms: i64) ![]u8 { + var view = try buildReplayArtifactViewWithEvidence(allocator, runtime, now_ms, workflowEvidenceForTest()); + defer view.deinit(allocator); + + return std.json.Stringify.valueAlloc(allocator, view.artifact, .{ .whitespace = .indent_2 }); +} + +fn workflowEvidenceForTest() WorkflowEvidence { + return .{ + .status = "available", + .failed_run = .{ + .run_id = "run-mission-code-red-primary", + .status = "failed", + .checkpoint_count = 1, + }, + .recovered_run = .{ + .run_id = "run-mission-code-red-recovered", + .status = "completed", + .checkpoint_count = 1, + }, + .checkpoint = .{ + .id = "ckpt-mission-code-red-failed", + .run_id = "run-mission-code-red-primary", + .step_id = "code.build", + }, + .scanned_run_count = 2, + }; +} + +test "buildSnapshotView returns idle mission before launch" { + const json = try snapshotJsonForTest(std.testing.allocator, .{}, 1_000); + defer std.testing.allocator.free(json); + + try std.testing.expect(std.mem.indexOf(u8, json, "\"schema_version\": 1") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"mode\": \"deterministic_local_replay\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"scenario_id\": \"mission-code-red\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"status\": \"idle\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"phase\": \"idle\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"workflow_evidence\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"can_launch\": true") != null); +} + +test "buildSnapshotView exposes failed mission and recover control" { + const json = try snapshotJsonForTest(std.testing.allocator, .{ + .launched = true, + .started_at_ms = 1_000, + }, 11_000); + defer std.testing.allocator.free(json); + + try std.testing.expect(std.mem.indexOf(u8, json, "\"status\": \"intervention_required\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"phase\": \"failed\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"can_recover\": true") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"replay_comparison\": null") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"artifact_role\": \"failed\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"artifact_role\": \"recovered\"") == null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"trace_id\": \"trace-mission-code-red-primary\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"eval_key\": \"tool_success\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "zig build test exited with status 1") != null); +} + +test "buildSnapshotView exposes recovered completed mission" { + const json = try snapshotJsonForTest(std.testing.allocator, .{ + .launched = true, + .started_at_ms = 1_000, + .recovered = true, + .recovery_started_at_ms = 11_000, + }, 19_000); + defer std.testing.allocator.free(json); + + try std.testing.expect(std.mem.indexOf(u8, json, "\"status\": \"completed\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"phase\": \"completed\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"recovered_run_id\": \"run-mission-code-red-recovered\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"verdict\": \"pass\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"artifact_role\": \"recovered\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"tokens_delta\": 1520") != null); +} + +test "buildSnapshotView keeps recovered comparison hidden until completion" { + const json = try snapshotJsonForTest(std.testing.allocator, .{ + .launched = true, + .started_at_ms = 1_000, + .recovered = true, + .recovery_started_at_ms = 11_000, + }, 12_000); + defer std.testing.allocator.free(json); + + try std.testing.expect(std.mem.indexOf(u8, json, "\"status\": \"running\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"phase\": \"forking\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"recovered_run_id\": \"run-mission-code-red-recovered\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"replay_comparison\": null") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"artifact_role\": \"recovered\"") == null); +} + +test "buildReplayArtifactView exports fixture snapshot and ecosystem mapping" { + const json = try replayArtifactJsonForTest(std.testing.allocator, .{ + .launched = true, + .started_at_ms = 1_000, + .recovered = true, + .recovery_started_at_ms = 11_000, + }, 19_000); + defer std.testing.allocator.free(json); + + try std.testing.expect(std.mem.indexOf(u8, json, "\"artifact_kind\": \"nullhub.mission_control.replay\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"replay_fixture_path\": \"src/core/mission_control/code_red.v1.json\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"snapshot\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"replay_comparison\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"artifact_kind\": \"nullhub.mission_control.run_replay\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"checkpoint_reused\": true") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"replay_fixture\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"workflow_evidence\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"scanned_run_count\": 2") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"ecosystem_mapping\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"nullwatch\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"trace_ref_source\": \"events[].trace\"") != null); +} + +test "runtime commands enforce launch reset and recovery transitions" { + const allocator = std.testing.allocator; + var runtime = RuntimeState{}; + + try std.testing.expect(canLaunch(runtime)); + try std.testing.expect(launch(&runtime, 1_000)); + try std.testing.expect(!canLaunch(runtime)); + try std.testing.expect(!launch(&runtime, 2_000)); + try std.testing.expect(!try recover(allocator, &runtime, 5_000)); + + try std.testing.expect(try recover(allocator, &runtime, 11_000)); + try std.testing.expect(runtime.recovered); + try std.testing.expect(!try recover(allocator, &runtime, 12_000)); + + reset(&runtime); + try std.testing.expect(canLaunch(runtime)); + try std.testing.expect(!runtime.recovered); +} diff --git a/src/core/mission_control/code_red.v1.json b/src/core/mission_control/code_red.v1.json new file mode 100644 index 0000000..692f6dd --- /dev/null +++ b/src/core/mission_control/code_red.v1.json @@ -0,0 +1,505 @@ +{ + "schema_version": 1, + "mode": "deterministic_local_replay", + "scenario_id": "mission-code-red", + "scenario_version": "2026-05-06", + "title": "Ship a bug fix through autonomous agents", + "run_ids": { + "failed": "run-mission-code-red-failed", + "recovered": "run-mission-code-red-recovered" + }, + "checkpoint_id": "cp-mission-code-red-before-test", + "human_instruction": "apply missing validation guard", + "phases": [ + { + "id": "idle", + "track": "idle", + "rank": 0, + "starts_at_ms": 0, + "status": "idle", + "progress": 0, + "headline": "Mission ready. Launch the local agent stack." + }, + { + "id": "launching", + "track": "primary", + "rank": 1, + "starts_at_ms": 0, + "status": "running", + "progress": 8, + "headline": "Backlog item claimed and NullBoiler run started." + }, + { + "id": "research", + "track": "primary", + "rank": 2, + "starts_at_ms": 1500, + "status": "running", + "progress": 20, + "headline": "Research agent is isolating the defect." + }, + { + "id": "coding", + "track": "primary", + "rank": 3, + "starts_at_ms": 3500, + "status": "running", + "progress": 36, + "headline": "Coder agent is applying a targeted patch." + }, + { + "id": "checkpoint", + "track": "primary", + "rank": 4, + "starts_at_ms": 5500, + "status": "running", + "progress": 48, + "headline": "Checkpoint captured before validation." + }, + { + "id": "testing", + "track": "primary", + "rank": 5, + "starts_at_ms": 7000, + "status": "running", + "progress": 60, + "headline": "Test runner is validating the patch." + }, + { + "id": "failed", + "track": "primary", + "rank": 6, + "starts_at_ms": 9000, + "status": "intervention_required", + "progress": 64, + "headline": "Validation failed. Human intervention is required." + }, + { + "id": "forking", + "track": "recovery", + "rank": 7, + "starts_at_ms": 0, + "status": "running", + "progress": 70, + "headline": "Forking from checkpoint with human guidance." + }, + { + "id": "patching", + "track": "recovery", + "rank": 8, + "starts_at_ms": 1500, + "status": "running", + "progress": 80, + "headline": "Recovered run is applying the injected fix." + }, + { + "id": "retesting", + "track": "recovery", + "rank": 9, + "starts_at_ms": 3500, + "status": "running", + "progress": 88, + "headline": "Recovered run is replaying tests." + }, + { + "id": "review", + "track": "recovery", + "rank": 10, + "starts_at_ms": 5500, + "status": "running", + "progress": 94, + "headline": "Reviewer agent is approving the recovered fix." + }, + { + "id": "completed", + "track": "recovery", + "rank": 11, + "starts_at_ms": 7000, + "status": "completed", + "progress": 100, + "headline": "Mission recovered and completed." + } + ], + "agents": [ + { + "id": "agent-researcher", + "role": "researcher", + "active_phases": ["research"], + "done_after_phase": "research", + "blocked_phase": null, + "failed_phase": null, + "steps": [ + { + "phase": "research", + "step": "reading ticket and isolating scope" + } + ] + }, + { + "id": "agent-coder", + "role": "coder", + "active_phases": ["coding", "patching"], + "done_after_phase": "coding", + "blocked_phase": "failed", + "failed_phase": null, + "steps": [ + { + "phase": "coding", + "step": "editing implementation" + }, + { + "phase": "patching", + "step": "applying injected guard" + } + ] + }, + { + "id": "agent-test-runner", + "role": "test", + "active_phases": ["testing", "retesting"], + "done_after_phase": "testing", + "blocked_phase": null, + "failed_phase": "failed", + "steps": [ + { + "phase": "testing", + "step": "running failing validation" + }, + { + "phase": "retesting", + "step": "replaying validation" + } + ] + }, + { + "id": "agent-reviewer", + "role": "reviewer", + "active_phases": ["review"], + "done_after_phase": "review", + "blocked_phase": null, + "failed_phase": null, + "steps": [ + { + "phase": "review", + "step": "checking recovered patch" + } + ] + } + ], + "graph": { + "nodes": [ + { + "id": "ticket", + "label": "Ticket", + "kind": "tracker", + "phase": "launching", + "error_phase": null + }, + { + "id": "research", + "label": "Research", + "kind": "agent", + "phase": "research", + "error_phase": null + }, + { + "id": "code", + "label": "Patch", + "kind": "agent", + "phase": "coding", + "error_phase": null + }, + { + "id": "checkpoint", + "label": "Checkpoint", + "kind": "checkpoint", + "phase": "checkpoint", + "error_phase": null + }, + { + "id": "test", + "label": "Test", + "kind": "tool", + "phase": "testing", + "error_phase": "failed" + }, + { + "id": "recover", + "label": "Fork", + "kind": "human", + "phase": "forking", + "error_phase": null + }, + { + "id": "review", + "label": "Review", + "kind": "agent", + "phase": "review", + "error_phase": null + } + ], + "edges": [ + { + "from": "ticket", + "to": "research", + "phase": "research", + "error_phase": null + }, + { + "from": "research", + "to": "code", + "phase": "coding", + "error_phase": null + }, + { + "from": "code", + "to": "checkpoint", + "phase": "checkpoint", + "error_phase": null + }, + { + "from": "checkpoint", + "to": "test", + "phase": "testing", + "error_phase": "failed" + }, + { + "from": "checkpoint", + "to": "recover", + "phase": "forking", + "error_phase": null + }, + { + "from": "recover", + "to": "review", + "phase": "review", + "error_phase": null + } + ] + }, + "events": [ + { + "at_ms": 0, + "phase": "launching", + "source": "nulltickets", + "level": "info", + "title": "Task queued", + "detail": "Mission task entered the durable backlog.", + "trace": { + "kind": "span", + "run_id": "run-mission-code-red-failed", + "trace_id": "trace-mission-code-red-primary", + "span_id": "span-ticket-queued", + "operation": "ticket.claim" + } + }, + { + "at_ms": 1500, + "phase": "research", + "source": "nullboiler", + "level": "info", + "title": "Workflow run started", + "detail": "Graph runtime selected researcher -> coder -> test -> review.", + "trace": { + "kind": "span", + "run_id": "run-mission-code-red-failed", + "trace_id": "trace-mission-code-red-primary", + "span_id": "span-workflow-started", + "operation": "workflow.dispatch" + } + }, + { + "at_ms": 3500, + "phase": "coding", + "source": "nullclaw", + "level": "info", + "title": "Patch drafted", + "detail": "Coder agent produced a minimal implementation patch.", + "trace": { + "kind": "span", + "run_id": "run-mission-code-red-failed", + "trace_id": "trace-mission-code-red-primary", + "span_id": "span-agent-coder", + "operation": "agent.coder.patch" + } + }, + { + "at_ms": 5500, + "phase": "checkpoint", + "source": "nullboiler", + "level": "info", + "title": "Checkpoint created", + "detail": "State saved as cp-mission-code-red-before-test.", + "trace": { + "kind": "span", + "run_id": "run-mission-code-red-failed", + "trace_id": "trace-mission-code-red-primary", + "span_id": "span-checkpoint-created", + "operation": "workflow.checkpoint" + } + }, + { + "at_ms": 7000, + "phase": "failed", + "source": "nullwatch", + "level": "error", + "title": "Validation failed", + "detail": "tool.call shell returned exit code 1.", + "trace": { + "kind": "eval", + "run_id": "run-mission-code-red-failed", + "trace_id": "trace-mission-code-red-primary", + "span_id": "span-test-failure", + "eval_key": "tool_success", + "operation": "tool.shell.zig_build_test" + } + }, + { + "at_ms": 9000, + "phase": "failed", + "source": "human", + "level": "warning", + "title": "Intervention requested", + "detail": "Fork from checkpoint and inject validation guard.", + "trace": { + "kind": "span", + "run_id": "run-mission-code-red-failed", + "trace_id": "trace-mission-code-red-primary", + "span_id": "span-human-intervention", + "operation": "human.checkpoint_fork" + } + }, + { + "at_ms": 10500, + "phase": "forking", + "source": "nullboiler", + "level": "info", + "title": "Recovered fork started", + "detail": "run-mission-code-red-recovered replayed from cp-mission-code-red-before-test.", + "trace": { + "kind": "span", + "run_id": "run-mission-code-red-recovered", + "trace_id": "trace-mission-code-red-recovered", + "span_id": "span-recovered-fork", + "operation": "workflow.fork" + } + }, + { + "at_ms": 13000, + "phase": "retesting", + "source": "nullwatch", + "level": "info", + "title": "Recovered tests passed", + "detail": "tool_success eval changed from fail to pass.", + "trace": { + "kind": "eval", + "run_id": "run-mission-code-red-recovered", + "trace_id": "trace-mission-code-red-recovered", + "span_id": "span-recovered-tests", + "eval_key": "tool_success", + "operation": "tool.shell.zig_build_test" + } + }, + { + "at_ms": 15000, + "phase": "completed", + "source": "nulltickets", + "level": "success", + "title": "Mission done", + "detail": "Ticket moved to review-approved terminal stage.", + "trace": { + "kind": "span", + "run_id": "run-mission-code-red-recovered", + "trace_id": "trace-mission-code-red-recovered", + "span_id": "span-ticket-completed", + "operation": "ticket.complete" + } + } + ], + "telemetry": [ + { + "phase": "idle", + "runs": 0, + "spans": 0, + "evals": 0, + "errors": 0, + "total_tokens": 0, + "total_cost_usd": 0.0, + "verdict": "not_started" + }, + { + "phase": "research", + "runs": 1, + "spans": 2, + "evals": 0, + "errors": 0, + "total_tokens": 740, + "total_cost_usd": 0.006, + "verdict": "running" + }, + { + "phase": "checkpoint", + "runs": 1, + "spans": 5, + "evals": 1, + "errors": 0, + "total_tokens": 1810, + "total_cost_usd": 0.017, + "verdict": "running" + }, + { + "phase": "testing", + "runs": 1, + "spans": 7, + "evals": 1, + "errors": 0, + "total_tokens": 2320, + "total_cost_usd": 0.021, + "verdict": "running" + }, + { + "phase": "failed", + "runs": 1, + "spans": 8, + "evals": 2, + "errors": 2, + "total_tokens": 2760, + "total_cost_usd": 0.022, + "verdict": "fail" + }, + { + "phase": "retesting", + "runs": 2, + "spans": 13, + "evals": 3, + "errors": 2, + "total_tokens": 3910, + "total_cost_usd": 0.033, + "verdict": "recovering" + }, + { + "phase": "completed", + "runs": 2, + "spans": 16, + "evals": 4, + "errors": 2, + "total_tokens": 4280, + "total_cost_usd": 0.036, + "verdict": "pass" + } + ], + "failure": { + "visible_from_phase": "failed", + "run_id": "run-mission-code-red-failed", + "checkpoint_id": "cp-mission-code-red-before-test", + "failed_step": "test", + "error_message": "zig build test exited with status 1", + "suggested_intervention": "Fork from checkpoint and inject the missing validation guard." + }, + "recovery": { + "visible_from_phase": "forking", + "run_id": "run-mission-code-red-recovered", + "forked_from": "cp-mission-code-red-before-test", + "human_instruction": "apply missing validation guard" + } +} diff --git a/src/core/mission_control_replay.zig b/src/core/mission_control_replay.zig new file mode 100644 index 0000000..bf13b9c --- /dev/null +++ b/src/core/mission_control_replay.zig @@ -0,0 +1,513 @@ +const std = @import("std"); + +pub const expected_schema_version: u8 = 1; +pub const embedded_json = @embedFile("mission_control/code_red.v1.json"); + +pub const ReplayFixture = struct { + schema_version: u8, + mode: []const u8, + scenario_id: []const u8, + scenario_version: []const u8, + title: []const u8, + run_ids: RunIds, + checkpoint_id: []const u8, + human_instruction: []const u8, + phases: []const PhaseDef, + agents: []const AgentDef, + graph: GraphDef, + events: []const EventDef, + telemetry: []const TelemetryDef, + failure: FailureDef, + recovery: RecoveryDef, +}; + +pub const RunIds = struct { + failed: []const u8, + recovered: []const u8, +}; + +pub const PhaseDef = struct { + id: []const u8, + track: []const u8, + rank: u8, + starts_at_ms: i64, + status: []const u8, + progress: u8, + headline: []const u8, +}; + +pub const AgentDef = struct { + id: []const u8, + role: []const u8, + active_phases: []const []const u8, + done_after_phase: []const u8, + blocked_phase: ?[]const u8, + failed_phase: ?[]const u8, + steps: []const AgentStepDef, +}; + +pub const AgentStepDef = struct { + phase: []const u8, + step: []const u8, +}; + +pub const GraphDef = struct { + nodes: []const GraphNodeDef, + edges: []const GraphEdgeDef, +}; + +pub const GraphNodeDef = struct { + id: []const u8, + label: []const u8, + kind: []const u8, + phase: []const u8, + error_phase: ?[]const u8, +}; + +pub const GraphEdgeDef = struct { + from: []const u8, + to: []const u8, + phase: []const u8, + error_phase: ?[]const u8, +}; + +pub const EventDef = struct { + at_ms: i64, + phase: []const u8, + source: []const u8, + level: []const u8, + title: []const u8, + detail: []const u8, + trace: ?EventTraceDef = null, +}; + +pub const EventTraceDef = struct { + kind: []const u8, + run_id: ?[]const u8 = null, + trace_id: ?[]const u8 = null, + span_id: ?[]const u8 = null, + eval_key: ?[]const u8 = null, + operation: []const u8, +}; + +pub const TelemetryDef = struct { + phase: []const u8, + runs: usize, + spans: usize, + evals: usize, + errors: usize, + total_tokens: usize, + total_cost_usd: f64, + verdict: []const u8, +}; + +pub const FailureDef = struct { + visible_from_phase: []const u8, + run_id: []const u8, + checkpoint_id: []const u8, + failed_step: []const u8, + error_message: []const u8, + suggested_intervention: []const u8, +}; + +pub const RecoveryDef = struct { + visible_from_phase: []const u8, + run_id: []const u8, + forked_from: []const u8, + human_instruction: []const u8, +}; + +pub const ValidationError = error{ + UnsupportedReplaySchema, + InvalidReplayFixture, + DuplicateReplayId, + UnknownReplayReference, + UnsortedReplayFixture, +}; + +pub fn parse(allocator: std.mem.Allocator) !std.json.Parsed(ReplayFixture) { + return parseBytes(allocator, embedded_json); +} + +pub fn parseBytes(allocator: std.mem.Allocator, bytes: []const u8) !std.json.Parsed(ReplayFixture) { + return std.json.parseFromSlice(ReplayFixture, allocator, bytes, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = false, + }); +} + +pub fn parseValidated(allocator: std.mem.Allocator) !std.json.Parsed(ReplayFixture) { + var parsed = try parse(allocator); + errdefer parsed.deinit(); + try validate(parsed.value); + return parsed; +} + +pub fn validate(fixture: ReplayFixture) ValidationError!void { + if (fixture.schema_version != expected_schema_version) return error.UnsupportedReplaySchema; + try requireNonEmpty(fixture.mode); + try requireNonEmpty(fixture.scenario_id); + try requireNonEmpty(fixture.scenario_version); + try requireNonEmpty(fixture.title); + try requireNonEmpty(fixture.run_ids.failed); + try requireNonEmpty(fixture.run_ids.recovered); + try requireNonEmpty(fixture.checkpoint_id); + try requireNonEmpty(fixture.human_instruction); + + try validatePhases(fixture); + try requirePhase(fixture, "idle"); + try requirePhase(fixture, "failed"); + try requirePhase(fixture, "completed"); + try validateAgents(fixture); + try validateGraph(fixture); + try validateEvents(fixture); + try validateTelemetry(fixture); + try validateFailure(fixture.failure, fixture); + try validateRecovery(fixture.recovery, fixture); +} + +pub fn phaseById(fixture: ReplayFixture, id: []const u8) ?PhaseDef { + for (fixture.phases) |phase| { + if (std.mem.eql(u8, phase.id, id)) return phase; + } + return null; +} + +pub fn phaseRank(fixture: ReplayFixture, id: []const u8) ?u8 { + return if (phaseById(fixture, id)) |phase| phase.rank else null; +} + +fn validatePhases(fixture: ReplayFixture) ValidationError!void { + if (fixture.phases.len == 0) return error.InvalidReplayFixture; + + for (fixture.phases, 0..) |phase, index| { + try requireNonEmpty(phase.id); + try requireNonEmpty(phase.track); + try requireNonEmpty(phase.status); + try requireNonEmpty(phase.headline); + if (!isKnownTrack(phase.track)) return error.InvalidReplayFixture; + if (phase.progress > 100) return error.InvalidReplayFixture; + if (phase.starts_at_ms < 0) return error.InvalidReplayFixture; + + for (fixture.phases[0..index]) |previous| { + if (std.mem.eql(u8, previous.id, phase.id)) return error.DuplicateReplayId; + if (previous.rank == phase.rank) return error.DuplicateReplayId; + } + } + + try validateTrackOrdering(fixture, "primary"); + try validateTrackOrdering(fixture, "recovery"); +} + +fn validateTrackOrdering(fixture: ReplayFixture, track: []const u8) ValidationError!void { + var seen = false; + var last_start: i64 = 0; + for (fixture.phases) |phase| { + if (!std.mem.eql(u8, phase.track, track)) continue; + if (seen and phase.starts_at_ms < last_start) return error.UnsortedReplayFixture; + seen = true; + last_start = phase.starts_at_ms; + } + if (!seen) return error.InvalidReplayFixture; +} + +fn validateAgents(fixture: ReplayFixture) ValidationError!void { + if (fixture.agents.len == 0) return error.InvalidReplayFixture; + + for (fixture.agents, 0..) |agent, index| { + try requireNonEmpty(agent.id); + try requireNonEmpty(agent.role); + try requirePhase(fixture, agent.done_after_phase); + if (agent.active_phases.len == 0) return error.InvalidReplayFixture; + for (agent.active_phases) |phase| try requirePhase(fixture, phase); + if (agent.blocked_phase) |phase| try requirePhase(fixture, phase); + if (agent.failed_phase) |phase| try requirePhase(fixture, phase); + for (agent.steps) |step| { + try requirePhase(fixture, step.phase); + try requireNonEmpty(step.step); + } + + for (fixture.agents[0..index]) |previous| { + if (std.mem.eql(u8, previous.id, agent.id)) return error.DuplicateReplayId; + } + } +} + +fn validateGraph(fixture: ReplayFixture) ValidationError!void { + if (fixture.graph.nodes.len == 0) return error.InvalidReplayFixture; + + for (fixture.graph.nodes, 0..) |node, index| { + try requireNonEmpty(node.id); + try requireNonEmpty(node.label); + try requireNonEmpty(node.kind); + try requirePhase(fixture, node.phase); + if (node.error_phase) |phase| try requirePhase(fixture, phase); + + for (fixture.graph.nodes[0..index]) |previous| { + if (std.mem.eql(u8, previous.id, node.id)) return error.DuplicateReplayId; + } + } + + for (fixture.graph.edges) |edge| { + try requireNode(fixture, edge.from); + try requireNode(fixture, edge.to); + try requirePhase(fixture, edge.phase); + if (edge.error_phase) |phase| try requirePhase(fixture, phase); + } +} + +fn validateEvents(fixture: ReplayFixture) ValidationError!void { + if (fixture.events.len == 0) return error.InvalidReplayFixture; + var last_at_ms: i64 = 0; + for (fixture.events, 0..) |event, index| { + if (event.at_ms < 0) return error.InvalidReplayFixture; + if (index > 0 and event.at_ms < last_at_ms) return error.UnsortedReplayFixture; + last_at_ms = event.at_ms; + try requirePhase(fixture, event.phase); + try requireNonEmpty(event.source); + try requireNonEmpty(event.level); + try requireNonEmpty(event.title); + try requireNonEmpty(event.detail); + if (event.trace) |trace| try validateEventTrace(trace, fixture); + } +} + +fn validateEventTrace(trace: EventTraceDef, fixture: ReplayFixture) ValidationError!void { + try requireNonEmpty(trace.kind); + try requireNonEmpty(trace.operation); + if (!std.mem.eql(u8, trace.kind, "span") and !std.mem.eql(u8, trace.kind, "eval")) { + return error.InvalidReplayFixture; + } + + if (trace.run_id) |run_id| { + try requireNonEmpty(run_id); + try requireRun(fixture, run_id); + } + if (trace.trace_id) |trace_id| try requireNonEmpty(trace_id); + if (trace.span_id) |span_id| try requireNonEmpty(span_id); + if (trace.eval_key) |eval_key| try requireNonEmpty(eval_key); + + if (std.mem.eql(u8, trace.kind, "span") and trace.span_id == null) return error.InvalidReplayFixture; + if (std.mem.eql(u8, trace.kind, "eval") and trace.eval_key == null) return error.InvalidReplayFixture; +} + +fn validateTelemetry(fixture: ReplayFixture) ValidationError!void { + if (fixture.telemetry.len == 0) return error.InvalidReplayFixture; + for (fixture.telemetry) |entry| { + try requirePhase(fixture, entry.phase); + try requireNonEmpty(entry.verdict); + if (entry.errors > entry.spans) return error.InvalidReplayFixture; + if (entry.evals > entry.spans) return error.InvalidReplayFixture; + if (entry.total_cost_usd < 0) return error.InvalidReplayFixture; + } +} + +fn validateFailure(failure: FailureDef, fixture: ReplayFixture) ValidationError!void { + try requirePhase(fixture, failure.visible_from_phase); + try requireNonEmpty(failure.run_id); + try requireRun(fixture, failure.run_id); + try requireNonEmpty(failure.checkpoint_id); + try requireNonEmpty(failure.failed_step); + try requireNode(fixture, failure.failed_step); + try requireNonEmpty(failure.error_message); + try requireNonEmpty(failure.suggested_intervention); +} + +fn validateRecovery(recovery: RecoveryDef, fixture: ReplayFixture) ValidationError!void { + try requirePhase(fixture, recovery.visible_from_phase); + try requireNonEmpty(recovery.run_id); + try requireRun(fixture, recovery.run_id); + try requireNonEmpty(recovery.forked_from); + try requireNonEmpty(recovery.human_instruction); +} + +fn requirePhase(fixture: ReplayFixture, id: []const u8) ValidationError!void { + if (phaseById(fixture, id) == null) return error.UnknownReplayReference; +} + +fn requireNode(fixture: ReplayFixture, id: []const u8) ValidationError!void { + for (fixture.graph.nodes) |node| { + if (std.mem.eql(u8, node.id, id)) return; + } + return error.UnknownReplayReference; +} + +fn requireRun(fixture: ReplayFixture, id: []const u8) ValidationError!void { + if (std.mem.eql(u8, id, fixture.run_ids.failed)) return; + if (std.mem.eql(u8, id, fixture.run_ids.recovered)) return; + return error.UnknownReplayReference; +} + +fn requireNonEmpty(value: []const u8) ValidationError!void { + if (value.len == 0) return error.InvalidReplayFixture; +} + +fn isKnownTrack(track: []const u8) bool { + return std.mem.eql(u8, track, "idle") or + std.mem.eql(u8, track, "primary") or + std.mem.eql(u8, track, "recovery"); +} + +test "embedded mission replay fixture validates" { + var parsed = try parseValidated(std.testing.allocator); + defer parsed.deinit(); + + try std.testing.expectEqual(@as(u8, expected_schema_version), parsed.value.schema_version); + try std.testing.expectEqualStrings("mission-code-red", parsed.value.scenario_id); + try std.testing.expect(phaseById(parsed.value, "completed") != null); +} + +test "validate rejects duplicate phase ids" { + const phases = [_]PhaseDef{ + .{ .id = "idle", .track = "idle", .rank = 0, .starts_at_ms = 0, .status = "idle", .progress = 0, .headline = "idle" }, + .{ .id = "failed", .track = "primary", .rank = 1, .starts_at_ms = 0, .status = "intervention_required", .progress = 60, .headline = "failed" }, + .{ .id = "failed", .track = "recovery", .rank = 2, .starts_at_ms = 0, .status = "completed", .progress = 100, .headline = "done" }, + }; + const fixture = minimalFixture(phases[0..], test_nodes[0..], test_edges[0..], test_events[0..], test_telemetry[0..]); + try std.testing.expectError(error.DuplicateReplayId, validate(fixture)); +} + +test "validate rejects graph edges pointing at unknown nodes" { + const edges = [_]GraphEdgeDef{ + .{ .from = "ticket", .to = "missing", .phase = "failed", .error_phase = null }, + }; + const fixture = minimalFixture(test_phases[0..], test_nodes[0..], edges[0..], test_events[0..], test_telemetry[0..]); + try std.testing.expectError(error.UnknownReplayReference, validate(fixture)); +} + +test "validate rejects telemetry for unknown phases" { + const telemetry = [_]TelemetryDef{ + .{ .phase = "missing", .runs = 1, .spans = 1, .evals = 0, .errors = 0, .total_tokens = 1, .total_cost_usd = 0.001, .verdict = "running" }, + }; + const fixture = minimalFixture(test_phases[0..], test_nodes[0..], test_edges[0..], test_events[0..], telemetry[0..]); + try std.testing.expectError(error.UnknownReplayReference, validate(fixture)); +} + +test "validate rejects trace refs for unknown run ids" { + const events = [_]EventDef{ + .{ + .at_ms = 0, + .phase = "launching", + .source = "nullwatch", + .level = "info", + .title = "event", + .detail = "detail", + .trace = .{ + .kind = "span", + .run_id = "missing-run", + .trace_id = "trace", + .span_id = "span", + .operation = "agent.step", + }, + }, + }; + const fixture = minimalFixture(test_phases[0..], test_nodes[0..], test_edges[0..], events[0..], test_telemetry[0..]); + try std.testing.expectError(error.UnknownReplayReference, validate(fixture)); +} + +test "validate rejects failure and recovery panels with unknown run ids" { + var fixture = minimalFixture(test_phases[0..], test_nodes[0..], test_edges[0..], test_events[0..], test_telemetry[0..]); + fixture.failure.run_id = "missing-failed-run"; + try std.testing.expectError(error.UnknownReplayReference, validate(fixture)); + + fixture = minimalFixture(test_phases[0..], test_nodes[0..], test_edges[0..], test_events[0..], test_telemetry[0..]); + fixture.recovery.run_id = "missing-recovered-run"; + try std.testing.expectError(error.UnknownReplayReference, validate(fixture)); +} + +test "validate rejects eval trace refs without eval keys" { + const events = [_]EventDef{ + .{ + .at_ms = 0, + .phase = "launching", + .source = "nullwatch", + .level = "info", + .title = "event", + .detail = "detail", + .trace = .{ + .kind = "eval", + .run_id = "failed-run", + .trace_id = "trace", + .span_id = "span", + .operation = "eval.tool_success", + }, + }, + }; + const fixture = minimalFixture(test_phases[0..], test_nodes[0..], test_edges[0..], events[0..], test_telemetry[0..]); + try std.testing.expectError(error.InvalidReplayFixture, validate(fixture)); +} + +fn minimalFixture( + phases: []const PhaseDef, + nodes: []const GraphNodeDef, + edges: []const GraphEdgeDef, + events: []const EventDef, + telemetry: []const TelemetryDef, +) ReplayFixture { + return .{ + .schema_version = expected_schema_version, + .mode = "deterministic_local_replay", + .scenario_id = "test", + .scenario_version = "v1", + .title = "test", + .run_ids = .{ .failed = "failed-run", .recovered = "recovered-run" }, + .checkpoint_id = "checkpoint", + .human_instruction = "fix", + .phases = phases, + .agents = test_agents[0..], + .graph = .{ .nodes = nodes, .edges = edges }, + .events = events, + .telemetry = telemetry, + .failure = .{ + .visible_from_phase = "failed", + .run_id = "failed-run", + .checkpoint_id = "checkpoint", + .failed_step = "ticket", + .error_message = "failed", + .suggested_intervention = "recover", + }, + .recovery = .{ + .visible_from_phase = "completed", + .run_id = "recovered-run", + .forked_from = "checkpoint", + .human_instruction = "fix", + }, + }; +} + +const test_phases = [_]PhaseDef{ + .{ .id = "idle", .track = "idle", .rank = 0, .starts_at_ms = 0, .status = "idle", .progress = 0, .headline = "idle" }, + .{ .id = "launching", .track = "primary", .rank = 1, .starts_at_ms = 0, .status = "running", .progress = 10, .headline = "launch" }, + .{ .id = "failed", .track = "primary", .rank = 2, .starts_at_ms = 100, .status = "intervention_required", .progress = 60, .headline = "failed" }, + .{ .id = "completed", .track = "recovery", .rank = 3, .starts_at_ms = 0, .status = "completed", .progress = 100, .headline = "done" }, +}; + +const test_agent_steps = [_]AgentStepDef{ + .{ .phase = "failed", .step = "work" }, +}; + +const test_agents = [_]AgentDef{ + .{ + .id = "agent", + .role = "coder", + .active_phases = &.{"failed"}, + .done_after_phase = "failed", + .blocked_phase = null, + .failed_phase = null, + .steps = test_agent_steps[0..], + }, +}; + +const test_nodes = [_]GraphNodeDef{ + .{ .id = "ticket", .label = "Ticket", .kind = "tracker", .phase = "launching", .error_phase = null }, +}; + +const test_edges = [_]GraphEdgeDef{}; + +const test_events = [_]EventDef{ + .{ .at_ms = 0, .phase = "launching", .source = "test", .level = "info", .title = "event", .detail = "detail" }, +}; + +const test_telemetry = [_]TelemetryDef{ + .{ .phase = "idle", .runs = 0, .spans = 0, .evals = 0, .errors = 0, .total_tokens = 0, .total_cost_usd = 0, .verdict = "idle" }, +}; diff --git a/src/core/mission_replay_store.zig b/src/core/mission_replay_store.zig new file mode 100644 index 0000000..68403ca --- /dev/null +++ b/src/core/mission_replay_store.zig @@ -0,0 +1,421 @@ +const std = @import("std"); +const std_compat = @import("compat"); +const durable_file = @import("durable_file.zig"); +const mission_core = @import("mission_control.zig"); +const replay_fixture = @import("mission_control_replay.zig"); +const paths_mod = @import("paths.zig"); + +pub const max_artifact_bytes: usize = 8 * 1024 * 1024; +const extension = ".json"; + +pub const Metadata = struct { + generated_at_ms: i64, + scenario_id: []const u8, + scenario_version: []const u8, + mission_id: []const u8, + title: []const u8, + status: []const u8, + phase: []const u8, + artifact_kind: []const u8, +}; + +pub const Record = struct { + id: []const u8, + saved_at_ms: i64, + generated_at_ms: i64, + scenario_id: []const u8, + scenario_version: []const u8, + mission_id: []const u8, + title: []const u8, + status: []const u8, + phase: []const u8, + artifact_kind: []const u8, + artifact_path: []const u8, + size_bytes: usize, + + pub fn deinit(self: Record, allocator: std.mem.Allocator) void { + allocator.free(self.id); + allocator.free(self.scenario_id); + allocator.free(self.scenario_version); + allocator.free(self.mission_id); + allocator.free(self.title); + allocator.free(self.status); + allocator.free(self.phase); + allocator.free(self.artifact_kind); + allocator.free(self.artifact_path); + } +}; + +const ReplayCandidate = struct { + name: []u8, + saved_at_ms: i64, + size_bytes: usize, + + fn deinit(self: ReplayCandidate, allocator: std.mem.Allocator) void { + allocator.free(self.name); + } + + fn id(self: ReplayCandidate) []const u8 { + return self.name[0 .. self.name.len - extension.len]; + } +}; + +pub fn save( + allocator: std.mem.Allocator, + paths: paths_mod.Paths, + saved_at_ms: i64, + artifact_json: []const u8, +) !Record { + if (artifact_json.len > max_artifact_bytes) return error.ArtifactTooLarge; + var parsed = try parseValidatedReplayArtifact(allocator, artifact_json); + defer parsed.deinit(); + const metadata = metadataFromArtifact(parsed.value); + + const dir_path = try ensureReplayDir(allocator, paths); + defer allocator.free(dir_path); + + const id = try buildReplayId(allocator, saved_at_ms, metadata.scenario_id, metadata.phase); + defer allocator.free(id); + const filename = try std.fmt.allocPrint(allocator, "{s}{s}", .{ id, extension }); + defer allocator.free(filename); + const artifact_path = try std.fs.path.join(allocator, &.{ dir_path, filename }); + defer allocator.free(artifact_path); + + try writeArtifactAtomic(allocator, artifact_path, artifact_json); + + return recordFromMetadata(allocator, id, saved_at_ms, metadata, artifact_json.len + 1); +} + +pub fn list(allocator: std.mem.Allocator, paths: paths_mod.Paths, limit: usize) ![]Record { + const dir_path = try paths.missionReplayDir(allocator); + defer allocator.free(dir_path); + + var dir = std_compat.fs.openDirAbsolute(dir_path, .{}) catch |err| switch (err) { + error.FileNotFound => return allocator.alloc(Record, 0), + else => return err, + }; + defer dir.close(); + + var candidates: std.ArrayListUnmanaged(ReplayCandidate) = .empty; + defer { + for (candidates.items) |candidate| candidate.deinit(allocator); + candidates.deinit(allocator); + } + + var it = dir.iterate(); + while (try it.next()) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.name, extension)) continue; + + const id = entry.name[0 .. entry.name.len - extension.len]; + if (!isValidReplayId(id)) continue; + + const stat = dir.statFile(entry.name) catch continue; + const name = try allocator.dupe(u8, entry.name); + errdefer allocator.free(name); + try candidates.append(allocator, .{ + .name = name, + .saved_at_ms = parseSavedAtFromId(id) orelse 0, + .size_bytes = @intCast(stat.size), + }); + } + + std.mem.sort(ReplayCandidate, candidates.items, {}, candidateNewerFirst); + + var records: std.ArrayListUnmanaged(Record) = .empty; + errdefer for (records.items) |record| record.deinit(allocator); + defer records.deinit(allocator); + + for (candidates.items) |candidate| { + if (limit != 0 and records.items.len >= limit) break; + + const artifact_json = dir.readFileAlloc(allocator, candidate.name, max_artifact_bytes) catch continue; + defer allocator.free(artifact_json); + + var record = recordFromArtifactJson(allocator, candidate.id(), artifact_json, candidate.size_bytes) catch continue; + errdefer record.deinit(allocator); + try records.append(allocator, record); + } + + return try records.toOwnedSlice(allocator); +} + +pub fn read(allocator: std.mem.Allocator, paths: paths_mod.Paths, id: []const u8) ![]u8 { + if (!isValidReplayId(id)) return error.InvalidReplayId; + + const dir_path = try paths.missionReplayDir(allocator); + defer allocator.free(dir_path); + const filename = try std.fmt.allocPrint(allocator, "{s}{s}", .{ id, extension }); + defer allocator.free(filename); + const artifact_path = try std.fs.path.join(allocator, &.{ dir_path, filename }); + defer allocator.free(artifact_path); + + const file = try std_compat.fs.openFileAbsolute(artifact_path, .{}); + defer file.close(); + const artifact_json = try file.readToEndAlloc(allocator, max_artifact_bytes); + errdefer allocator.free(artifact_json); + var parsed = try parseValidatedReplayArtifact(allocator, artifact_json); + defer parsed.deinit(); + return artifact_json; +} + +pub fn deinitRecords(allocator: std.mem.Allocator, records: []Record) void { + for (records) |record| record.deinit(allocator); + allocator.free(records); +} + +fn recordFromMetadata(allocator: std.mem.Allocator, id: []const u8, saved_at_ms: i64, metadata: Metadata, size_bytes: usize) !Record { + const owned_id = try allocator.dupe(u8, id); + errdefer allocator.free(owned_id); + const scenario_id = try allocator.dupe(u8, metadata.scenario_id); + errdefer allocator.free(scenario_id); + const scenario_version = try allocator.dupe(u8, metadata.scenario_version); + errdefer allocator.free(scenario_version); + const mission_id = try allocator.dupe(u8, metadata.mission_id); + errdefer allocator.free(mission_id); + const title = try allocator.dupe(u8, metadata.title); + errdefer allocator.free(title); + const status = try allocator.dupe(u8, metadata.status); + errdefer allocator.free(status); + const phase = try allocator.dupe(u8, metadata.phase); + errdefer allocator.free(phase); + const artifact_kind = try allocator.dupe(u8, metadata.artifact_kind); + errdefer allocator.free(artifact_kind); + const artifact_path = try std.fmt.allocPrint(allocator, "mission-control/replays/{s}{s}", .{ id, extension }); + errdefer allocator.free(artifact_path); + + return .{ + .id = owned_id, + .saved_at_ms = saved_at_ms, + .generated_at_ms = metadata.generated_at_ms, + .scenario_id = scenario_id, + .scenario_version = scenario_version, + .mission_id = mission_id, + .title = title, + .status = status, + .phase = phase, + .artifact_kind = artifact_kind, + .artifact_path = artifact_path, + .size_bytes = size_bytes, + }; +} + +fn recordFromArtifactJson(allocator: std.mem.Allocator, id: []const u8, artifact_json: []const u8, size_bytes: usize) !Record { + var parsed = try parseValidatedReplayArtifact(allocator, artifact_json); + defer parsed.deinit(); + return recordFromArtifact(allocator, id, parsed.value, size_bytes); +} + +fn parseValidatedReplayArtifact(allocator: std.mem.Allocator, artifact_json: []const u8) !std.json.Parsed(mission_core.ReplayArtifact) { + var parsed = try std.json.parseFromSlice(mission_core.ReplayArtifact, allocator, artifact_json, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = false, + }); + errdefer parsed.deinit(); + try validateReplayArtifact(parsed.value); + return parsed; +} + +fn validateReplayArtifact(artifact: mission_core.ReplayArtifact) !void { + if (artifact.artifact_schema_version != 1) return error.InvalidReplayArtifact; + if (!std.mem.eql(u8, artifact.artifact_kind, "nullhub.mission_control.replay")) return error.InvalidReplayArtifact; + if (artifact.generated_at_ms <= 0) return error.InvalidReplayArtifact; + if (!std.mem.eql(u8, artifact.scenario_id, artifact.replay_fixture.scenario_id)) return error.InvalidReplayArtifact; + if (!std.mem.eql(u8, artifact.scenario_version, artifact.replay_fixture.scenario_version)) return error.InvalidReplayArtifact; + if (!std.mem.eql(u8, artifact.snapshot.scenario_id, artifact.scenario_id)) return error.InvalidReplayArtifact; + if (!std.mem.eql(u8, artifact.snapshot.scenario_version, artifact.scenario_version)) return error.InvalidReplayArtifact; + if (!std.mem.eql(u8, artifact.workflow_evidence.status, artifact.snapshot.workflow_evidence.status)) return error.InvalidReplayArtifact; + replay_fixture.validate(artifact.replay_fixture) catch return error.InvalidReplayArtifact; +} + +fn metadataFromArtifact(artifact: mission_core.ReplayArtifact) Metadata { + return .{ + .generated_at_ms = artifact.generated_at_ms, + .scenario_id = artifact.scenario_id, + .scenario_version = artifact.scenario_version, + .mission_id = artifact.snapshot.mission_id, + .title = artifact.snapshot.title, + .status = artifact.snapshot.status, + .phase = artifact.snapshot.phase, + .artifact_kind = artifact.artifact_kind, + }; +} + +fn recordFromArtifact(allocator: std.mem.Allocator, id: []const u8, artifact: mission_core.ReplayArtifact, size_bytes: usize) !Record { + const metadata = metadataFromArtifact(artifact); + return recordFromMetadata(allocator, id, parseSavedAtFromId(id) orelse artifact.generated_at_ms, metadata, size_bytes); +} + +fn writeArtifactAtomic(allocator: std.mem.Allocator, artifact_path: []const u8, artifact_json: []const u8) !void { + try durable_file.writeTextFileAtomically(allocator, artifact_path, artifact_json); +} + +fn ensureReplayDir(allocator: std.mem.Allocator, paths: paths_mod.Paths) ![]const u8 { + std_compat.fs.makeDirAbsolute(paths.root) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + + const mission_dir = try std.fs.path.join(allocator, &.{ paths.root, "mission-control" }); + defer allocator.free(mission_dir); + std_compat.fs.makeDirAbsolute(mission_dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + + const dir_path = try paths.missionReplayDir(allocator); + errdefer allocator.free(dir_path); + std_compat.fs.makeDirAbsolute(dir_path) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + return dir_path; +} + +fn buildReplayId(allocator: std.mem.Allocator, saved_at_ms: i64, scenario_id: []const u8, phase: []const u8) ![]u8 { + const scenario = try sanitizeIdPart(allocator, scenario_id); + defer allocator.free(scenario); + const phase_part = try sanitizeIdPart(allocator, phase); + defer allocator.free(phase_part); + return std.fmt.allocPrint(allocator, "{d}-{s}-{s}-{x}", .{ + saved_at_ms, + scenario, + phase_part, + std_compat.crypto.random.int(u64), + }); +} + +fn sanitizeIdPart(allocator: std.mem.Allocator, value: []const u8) ![]u8 { + var out: std.ArrayListUnmanaged(u8) = .empty; + errdefer out.deinit(allocator); + var last_dash = false; + for (value) |byte| { + const safe = std.ascii.isAlphanumeric(byte) or byte == '.' or byte == '_' or byte == '-'; + const ch: u8 = if (safe) std.ascii.toLower(byte) else '-'; + if (ch == '-') { + if (last_dash) continue; + last_dash = true; + } else { + last_dash = false; + } + try out.append(allocator, ch); + if (out.items.len >= 64) break; + } + if (out.items.len == 0) try out.appendSlice(allocator, "mission"); + while (out.items.len > 1 and out.items[out.items.len - 1] == '-') { + _ = out.pop(); + } + return try out.toOwnedSlice(allocator); +} + +fn isValidReplayId(id: []const u8) bool { + if (id.len == 0 or id.len > 180) return false; + for (id) |byte| { + if (!(std.ascii.isAlphanumeric(byte) or byte == '.' or byte == '_' or byte == '-')) return false; + } + return true; +} + +fn parseSavedAtFromId(id: []const u8) ?i64 { + const dash = std.mem.indexOfScalar(u8, id, '-') orelse return null; + return std.fmt.parseInt(i64, id[0..dash], 10) catch null; +} + +fn candidateNewerFirst(_: void, a: ReplayCandidate, b: ReplayCandidate) bool { + if (a.saved_at_ms == b.saved_at_ms) return std.mem.order(u8, a.id(), b.id()) == .gt; + return a.saved_at_ms > b.saved_at_ms; +} + +test "mission replay store saves lists and reads artifacts" { + const test_helpers = @import("../test_helpers.zig"); + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + + var view = try mission_core.buildReplayArtifactViewWithEvidence(allocator, .{ + .launched = true, + .started_at_ms = 1_000, + .recovered = true, + .recovery_started_at_ms = 11_000, + }, 20_000, replayEvidenceForTest()); + defer view.deinit(allocator); + const artifact = try std.json.Stringify.valueAlloc(allocator, view.artifact, .{ .whitespace = .indent_2 }); + defer allocator.free(artifact); + + var record = try save(allocator, fixture.paths, 20_000, artifact); + defer record.deinit(allocator); + try std.testing.expect(std.mem.startsWith(u8, record.id, "20000-mission-code-red-completed-")); + + const body = try read(allocator, fixture.paths, record.id); + defer allocator.free(body); + try std.testing.expect(std.mem.indexOf(u8, body, "\"scenario_id\": \"mission-code-red\"") != null); + + const records = try list(allocator, fixture.paths, 10); + defer deinitRecords(allocator, records); + try std.testing.expectEqual(@as(usize, 1), records.len); + try std.testing.expectEqualStrings(record.id, records[0].id); + try std.testing.expectEqualStrings("completed", records[0].phase); +} + +fn replayEvidenceForTest() mission_core.WorkflowEvidence { + return .{ + .status = "available", + .failed_run = .{ + .run_id = "run-mission-code-red-primary", + .status = "failed", + .checkpoint_count = 1, + }, + .recovered_run = .{ + .run_id = "run-mission-code-red-recovered", + .status = "completed", + .checkpoint_count = 1, + }, + .checkpoint = .{ + .id = "ckpt-mission-code-red-failed", + .run_id = "run-mission-code-red-primary", + .step_id = "code.build", + }, + .scanned_run_count = 2, + }; +} + +test "mission replay store list and read do not create replay directories" { + const test_helpers = @import("../test_helpers.zig"); + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + + const records = try list(allocator, fixture.paths, 10); + defer deinitRecords(allocator, records); + try std.testing.expectEqual(@as(usize, 0), records.len); + + const mission_dir = try fixture.path(allocator, "mission-control"); + defer allocator.free(mission_dir); + try std.testing.expectError(error.FileNotFound, std_compat.fs.accessAbsolute(mission_dir, .{})); + + try std.testing.expectError( + error.FileNotFound, + read(allocator, fixture.paths, "1234-mission-code-red-completed-deadbeef"), + ); +} + +test "mission replay store rejects corrupted persisted artifact reads" { + const test_helpers = @import("../test_helpers.zig"); + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + + const replay_dir = try ensureReplayDir(allocator, fixture.paths); + defer allocator.free(replay_dir); + const bad_path = try std.fs.path.join(allocator, &.{ replay_dir, "1234-mission-code-red-completed-deadbeef.json" }); + defer allocator.free(bad_path); + + var file = try std_compat.fs.createFileAbsolute(bad_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll("{\"artifact_kind\":\"wrong\"}"); + + try std.testing.expectError( + error.InvalidReplayArtifact, + read(allocator, fixture.paths, "1234-mission-code-red-completed-deadbeef"), + ); +} diff --git a/src/core/nullclaw_gateway_config.zig b/src/core/nullclaw_gateway_config.zig new file mode 100644 index 0000000..41139bf --- /dev/null +++ b/src/core/nullclaw_gateway_config.zig @@ -0,0 +1,372 @@ +const std = @import("std"); +const std_compat = @import("compat"); +const durable_file = @import("durable_file.zig"); +const paths_mod = @import("paths.zig"); + +pub const token_prefix = "nullhub-local-"; +pub const token_file = ".nullhub-gateway-token"; +pub const min_body_size: i64 = 25 * 1024 * 1024; +pub const min_timeout_secs: i64 = 120; + +const max_config_bytes = 4 * 1024 * 1024; + +pub const Options = struct { + require_pairing: bool = true, + max_body_size_bytes: i64 = min_body_size, + request_timeout_secs: i64 = min_timeout_secs, + a2a_enabled: bool = true, + a2a_multi_modal: bool = true, + stateless_memory: bool = false, +}; + +pub const Access = struct { + token: ?[]u8 = null, + changed: bool = false, + + pub fn deinit(self: *Access, allocator: std.mem.Allocator) void { + if (self.token) |token| allocator.free(token); + self.* = .{}; + } +}; + +pub fn ensureConfig( + allocator: std.mem.Allocator, + paths: paths_mod.Paths, + component: []const u8, + name: []const u8, + options: Options, +) !Access { + if (!std.mem.eql(u8, component, "nullclaw")) return error.UnsupportedComponent; + + const config_path = try paths.instanceConfig(allocator, component, name); + defer allocator.free(config_path); + + const contents = try readConfigOrEmpty(allocator, config_path); + defer allocator.free(contents); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, contents, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + if (parsed.value != .object) return error.InvalidConfig; + + const json_allocator = parsed.arena.allocator(); + var changed = false; + const root = &parsed.value.object; + const gateway_obj = try ensureObjectField(json_allocator, root, "gateway", &changed); + const a2a_obj = try ensureObjectField(json_allocator, root, "a2a", &changed); + + try setBoolField(json_allocator, gateway_obj, "require_pairing", options.require_pairing, &changed); + try setIntegerAtLeast(json_allocator, gateway_obj, "max_body_size_bytes", @max(options.max_body_size_bytes, min_body_size), &changed); + try setIntegerAtLeast(json_allocator, gateway_obj, "request_timeout_secs", @max(options.request_timeout_secs, min_timeout_secs), &changed); + try setBoolField(json_allocator, a2a_obj, "enabled", options.a2a_enabled, &changed); + try setBoolField(json_allocator, a2a_obj, "multi_modal", options.a2a_multi_modal, &changed); + + var token: ?[]u8 = null; + errdefer if (token) |owned| allocator.free(owned); + if (options.require_pairing) { + token = try ensurePairedToken(allocator, json_allocator, paths, component, name, gateway_obj, &changed); + } + + if (options.stateless_memory) { + const memory_obj = try ensureObjectField(json_allocator, root, "memory", &changed); + try setStringField(json_allocator, memory_obj, "profile", "minimal_none", &changed); + try setStringField(json_allocator, memory_obj, "backend", "none", &changed); + try setBoolField(json_allocator, memory_obj, "auto_save", false, &changed); + } + + if (changed) try writeJsonConfigValue(allocator, config_path, parsed.value); + + return .{ .token = token, .changed = changed }; +} + +pub fn loadAccess( + allocator: std.mem.Allocator, + paths: paths_mod.Paths, + component: []const u8, + name: []const u8, +) !Access { + if (!std.mem.eql(u8, component, "nullclaw")) return error.UnsupportedComponent; + + const token = try readStoredToken(allocator, paths, component, name) orelse return error.GatewayTokenMissing; + errdefer allocator.free(token); + + const config_path = try paths.instanceConfig(allocator, component, name); + defer allocator.free(config_path); + const file = try std_compat.fs.openFileAbsolute(config_path, .{}); + defer file.close(); + const contents = try file.readToEndAlloc(allocator, max_config_bytes); + defer allocator.free(contents); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, contents, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + if (parsed.value != .object) return error.InvalidConfig; + + const gateway_value = parsed.value.object.get("gateway") orelse return error.GatewayConfigMissing; + if (gateway_value != .object) return error.InvalidConfig; + if (!boolField(gateway_value.object, "require_pairing")) return error.GatewayPairingMissing; + + const token_hash = try hashGatewayTokenAlloc(allocator, token); + defer allocator.free(token_hash); + if (!pairedTokensContainHash(gateway_value.object, token_hash)) return error.GatewayPairingMissing; + + return .{ .token = token, .changed = false }; +} + +pub fn gatewayTokenPath( + allocator: std.mem.Allocator, + paths: paths_mod.Paths, + component: []const u8, + name: []const u8, +) ![]u8 { + const instance_dir = try paths.instanceDir(allocator, component, name); + defer allocator.free(instance_dir); + return std.fs.path.join(allocator, &.{ instance_dir, token_file }); +} + +pub fn isNullhubGatewayToken(token: []const u8) bool { + return std.mem.startsWith(u8, token, token_prefix); +} + +pub fn hashGatewayTokenAlloc(allocator: std.mem.Allocator, token: []const u8) ![]u8 { + var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(token, &digest, .{}); + const hex = "0123456789abcdef"; + var out = try allocator.alloc(u8, digest.len * 2); + for (digest, 0..) |b, i| { + out[i * 2] = hex[b >> 4]; + out[i * 2 + 1] = hex[b & 0x0f]; + } + return out; +} + +fn ensureObjectField( + allocator: std.mem.Allocator, + obj: *std.json.ObjectMap, + key: []const u8, + changed: *bool, +) !*std.json.ObjectMap { + const gop = try obj.getOrPut(allocator, key); + if (!gop.found_existing or gop.value_ptr.* != .object) { + gop.value_ptr.* = .{ .object = .empty }; + changed.* = true; + } + return &gop.value_ptr.object; +} + +fn setStringField( + allocator: std.mem.Allocator, + obj: *std.json.ObjectMap, + key: []const u8, + value: []const u8, + changed: *bool, +) !void { + if (obj.get(key)) |existing| { + if (existing == .string and std.mem.eql(u8, existing.string, value)) return; + } + try obj.put(allocator, key, .{ .string = try allocator.dupe(u8, value) }); + changed.* = true; +} + +fn setBoolField( + allocator: std.mem.Allocator, + obj: *std.json.ObjectMap, + key: []const u8, + value: bool, + changed: *bool, +) !void { + if (obj.get(key)) |existing| { + if (existing == .bool and existing.bool == value) return; + } + try obj.put(allocator, key, .{ .bool = value }); + changed.* = true; +} + +fn setIntegerAtLeast( + allocator: std.mem.Allocator, + obj: *std.json.ObjectMap, + key: []const u8, + minimum: i64, + changed: *bool, +) !void { + if (obj.get(key)) |existing| { + if (existing == .integer and existing.integer >= minimum) return; + } + try obj.put(allocator, key, .{ .integer = minimum }); + changed.* = true; +} + +fn generateToken(allocator: std.mem.Allocator) ![]u8 { + var random_bytes: [24]u8 = undefined; + std_compat.crypto.random.bytes(&random_bytes); + const hex = "0123456789abcdef"; + var token = try allocator.alloc(u8, token_prefix.len + random_bytes.len * 2); + @memcpy(token[0..token_prefix.len], token_prefix); + for (random_bytes, 0..) |b, i| { + token[token_prefix.len + i * 2] = hex[b >> 4]; + token[token_prefix.len + i * 2 + 1] = hex[b & 0x0f]; + } + return token; +} + +fn readStoredToken( + allocator: std.mem.Allocator, + paths: paths_mod.Paths, + component: []const u8, + name: []const u8, +) !?[]u8 { + const path = try gatewayTokenPath(allocator, paths, component, name); + defer allocator.free(path); + + const file = std_compat.fs.openFileAbsolute(path, .{}) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; + defer file.close(); + + const contents = try file.readToEndAlloc(allocator, 16 * 1024); + errdefer allocator.free(contents); + const trimmed = std.mem.trim(u8, contents, " \t\r\n"); + if (!isNullhubGatewayToken(trimmed)) { + allocator.free(contents); + return null; + } + if (trimmed.len == contents.len) return contents; + const token = try allocator.dupe(u8, trimmed); + allocator.free(contents); + return token; +} + +fn readConfigOrEmpty(allocator: std.mem.Allocator, config_path: []const u8) ![]u8 { + const file = std_compat.fs.openFileAbsolute(config_path, .{}) catch |err| switch (err) { + error.FileNotFound => return allocator.dupe(u8, "{}"), + else => return err, + }; + defer file.close(); + return try file.readToEndAlloc(allocator, max_config_bytes); +} + +fn writeStoredToken( + allocator: std.mem.Allocator, + paths: paths_mod.Paths, + component: []const u8, + name: []const u8, + token: []const u8, +) !void { + const path = try gatewayTokenPath(allocator, paths, component, name); + defer allocator.free(path); + + try durable_file.writeTextFileAtomicallyWithMode(allocator, path, token, 0o600); +} + +fn ensurePairedToken( + allocator: std.mem.Allocator, + json_allocator: std.mem.Allocator, + paths: paths_mod.Paths, + component: []const u8, + name: []const u8, + gateway_obj: *std.json.ObjectMap, + changed: *bool, +) ![]u8 { + const token = blk: { + if (try readStoredToken(allocator, paths, component, name)) |stored| { + break :blk stored; + } + const generated = try generateToken(allocator); + errdefer allocator.free(generated); + try writeStoredToken(allocator, paths, component, name, generated); + break :blk generated; + }; + errdefer allocator.free(token); + + const token_hash = try hashGatewayTokenAlloc(allocator, token); + defer allocator.free(token_hash); + + if (gateway_obj.getPtr("paired_tokens")) |tokens_value| { + if (tokens_value.* == .array) { + var has_hash = false; + var has_plaintext_nullhub_token = false; + for (tokens_value.array.items) |item| { + if (item == .string and std.mem.eql(u8, item.string, token_hash)) { + has_hash = true; + } else if (item == .string and isNullhubGatewayToken(item.string)) { + has_plaintext_nullhub_token = true; + } + } + + if (has_hash and !has_plaintext_nullhub_token) return token; + + var tokens = std.json.Array.init(json_allocator); + var inserted_hash = false; + for (tokens_value.array.items) |item| { + if (item == .string and isNullhubGatewayToken(item.string)) continue; + if (item == .string and std.mem.eql(u8, item.string, token_hash)) { + if (inserted_hash) continue; + inserted_hash = true; + } + try tokens.append(item); + } + if (!inserted_hash) { + try tokens.append(.{ .string = try json_allocator.dupe(u8, token_hash) }); + } + tokens_value.* = .{ .array = tokens }; + changed.* = true; + return token; + } + } + + var tokens = std.json.Array.init(json_allocator); + try tokens.append(.{ .string = try json_allocator.dupe(u8, token_hash) }); + try gateway_obj.put(json_allocator, "paired_tokens", .{ .array = tokens }); + changed.* = true; + return token; +} + +fn pairedTokensContainHash(gateway_obj: std.json.ObjectMap, token_hash: []const u8) bool { + const tokens_value = gateway_obj.get("paired_tokens") orelse return false; + if (tokens_value != .array) return false; + for (tokens_value.array.items) |item| { + if (item == .string and std.mem.eql(u8, item.string, token_hash)) return true; + } + return false; +} + +fn boolField(obj: std.json.ObjectMap, key: []const u8) bool { + const value = obj.get(key) orelse return false; + return value == .bool and value.bool; +} + +fn writeJsonConfigValue(allocator: std.mem.Allocator, config_path: []const u8, value: std.json.Value) !void { + const rendered = try std.json.Stringify.valueAlloc(allocator, value, .{ + .whitespace = .indent_2, + .emit_null_optional_fields = false, + }); + defer allocator.free(rendered); + + try durable_file.writeTextFileAtomically(allocator, config_path, rendered); +} + +test "ensureConfig creates missing nullclaw config" { + const test_helpers = @import("../test_helpers.zig"); + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + try fixture.paths.ensureDirs(); + + const instance_dir = try fixture.paths.instanceDir(allocator, "nullclaw", "hat"); + defer allocator.free(instance_dir); + try std_compat.fs.cwd().makePath(instance_dir); + + var access = try ensureConfig(allocator, fixture.paths, "nullclaw", "hat", .{}); + defer access.deinit(allocator); + try std.testing.expect(access.changed); + try std.testing.expect(access.token != null); + + var loaded = try loadAccess(allocator, fixture.paths, "nullclaw", "hat"); + defer loaded.deinit(allocator); + try std.testing.expectEqualStrings(access.token.?, loaded.token.?); +} diff --git a/src/core/paths.zig b/src/core/paths.zig index 23f4a97..e158440 100644 --- a/src/core/paths.zig +++ b/src/core/paths.zig @@ -9,6 +9,7 @@ const builtin = @import("builtin"); /// ~/.nullhub/ /// ├── config.json /// ├── state.json +/// ├── mission-control/replays/{id}.json /// ├── manifests/{component}@{version}.json /// ├── bin/{component}-{version} (or bin/{component} for dev-local) /// ├── instances/{component}/{name}/ @@ -53,6 +54,11 @@ pub const Paths = struct { return std.fs.path.join(allocator, &.{ self.root, "state.json" }); } + /// `{root}/mission-control/replays` + pub fn missionReplayDir(self: Paths, allocator: std.mem.Allocator) ![]const u8 { + return std.fs.path.join(allocator, &.{ self.root, "mission-control", "replays" }); + } + // ── Component paths ────────────────────────────────────────────── /// `{root}/manifests/{component}@{version}.json` @@ -131,6 +137,7 @@ pub const Paths = struct { "bin", "instances", "ui", + "mission-control/replays", "cache/downloads", "cache/usage", }; diff --git a/src/fs_compat.zig b/src/fs_compat.zig index 723a07c..88f1034 100644 --- a/src/fs_compat.zig +++ b/src/fs_compat.zig @@ -7,8 +7,8 @@ fn capped_read_limit(max_bytes: u64) usize { return @intCast(@min(max_bytes, max_usize_u64)); } -/// Compatibility wrapper for `Dir.readFileAlloc` that avoids Zig 0.15.2's -/// `File.stat()` path on Linux kernels where `statx` is unavailable. +/// Project filesystem helper that reads relative and absolute paths through the +/// shared Zig 0.16 stdlib adapter. pub fn readFileAlloc(dir: anytype, allocator: std.mem.Allocator, sub_path: []const u8, max_bytes: u64) ![]u8 { if (std.fs.path.isAbsolute(sub_path)) { const file = try openPath(sub_path, .{}); @@ -22,11 +22,8 @@ pub fn readFileAlloc(dir: anytype, allocator: std.mem.Allocator, sub_path: []con return try file.readToEndAlloc(allocator, capped_read_limit(max_bytes)); } -/// Compatibility wrapper for `Dir.makePath` / `cwd().makePath()` that avoids -/// the `statx`-dependent recursive path walk in Zig 0.15.2 stdlib. -/// -/// Each ancestor directory is created in order, treating existing -/// directories as success. +/// Project filesystem helper for creating each ancestor directory in order, +/// treating existing directories as success. pub fn makePath(path: []const u8) !void { if (path.len == 0) return; @@ -60,7 +57,7 @@ pub fn makePath(path: []const u8) !void { } } -/// Compatibility wrapper that forwards to the file's `stat` implementation. +/// Project filesystem helper that forwards to the file's `stat` implementation. pub fn stat(file: anytype) @TypeOf(file.stat()) { return file.stat(); } diff --git a/src/installer/orchestrator.zig b/src/installer/orchestrator.zig index b257d9b..4725209 100644 --- a/src/installer/orchestrator.zig +++ b/src/installer/orchestrator.zig @@ -12,11 +12,14 @@ const local_binary = @import("../core/local_binary.zig"); const fs_compat = @import("../fs_compat.zig"); const launch_args_mod = @import("../core/launch_args.zig"); const nullclaw_web_channel = @import("../core/nullclaw_web_channel.zig"); +const nullclaw_gateway_config = @import("../core/nullclaw_gateway_config.zig"); const manager_mod = @import("../supervisor/manager.zig"); const ui_modules_mod = @import("ui_modules.zig"); const managed_skills = @import("../managed_skills.zig"); const test_helpers = @import("../test_helpers.zig"); const MAX_CONFIG_BYTES = 4 * 1024 * 1024; +const min_gateway_body_size = nullclaw_gateway_config.min_body_size; +const min_gateway_timeout_secs = nullclaw_gateway_config.min_timeout_secs; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -206,7 +209,7 @@ pub fn install( ) catch null; break :blk owned_launch_command orelse comp.default_launch_command; } else comp.default_launch_command; - const launch_command = registry.normalizeLaunchCommand(opts.component, raw_launch_command); + const launch_command = raw_launch_command; const health_endpoint = if (parsed_manifest) |pm| pm.value.health.endpoint else comp.default_health_endpoint; const default_port = if (parsed_manifest) |pm| (if (pm.value.ports.len > 0) pm.value.ports[0].default else comp.default_port) else comp.default_port; defer if (parsed_manifest) |pm| pm.deinit(); @@ -222,12 +225,20 @@ pub fn install( // If any selected provider is openai-compatible (has a base_url), strip only those // entries before passing answers to the binary. The binary only knows standard // provider names; custom credentials and fallback order are restored afterwards. - const custom_provider_result = extractCustomProviders(allocator, opts.answers_json) catch |err| blk: { + const nullclaw_runtime_profile = parseNullclawRuntimeProfile(allocator, opts.component, opts.answers_json); + const answers_without_nullhub_hints = stripNullhubRuntimeProfileHints(allocator, opts.component, opts.answers_json, nullclaw_runtime_profile) catch |err| { + std.log.warn("stripNullhubRuntimeProfileHints failed: {s}", .{@errorName(err)}); + setLastErrorDetail("failed to prepare nullclaw runtime profile answers"); + return error.ConfigGenerationFailed; + }; + defer if (answers_without_nullhub_hints.ptr != opts.answers_json.ptr) allocator.free(answers_without_nullhub_hints); + + const custom_provider_result = extractCustomProviders(allocator, answers_without_nullhub_hints) catch |err| blk: { std.log.warn("extractCustomProviders failed: {s}", .{@errorName(err)}); break :blk null; }; defer if (custom_provider_result) |cp| cp.deinit(allocator); - const answers_for_binary = if (custom_provider_result) |cp| cp.stripped_json else opts.answers_json; + const answers_for_binary = if (custom_provider_result) |cp| cp.stripped_json else answers_without_nullhub_hints; const answers_with_port = injectPortFields(allocator, answers_for_binary, port, managed_port) catch answers_for_binary; defer if (answers_with_port.ptr != answers_for_binary.ptr) allocator.free(answers_with_port); @@ -268,6 +279,18 @@ pub fn install( } } + patchNullclawRuntimeProfileIntoConfig( + allocator, + p, + opts.component, + opts.instance_name, + nullclaw_runtime_profile, + ) catch |err| { + std.log.warn("failed to patch nullclaw runtime profile config: {s}", .{@errorName(err)}); + setLastErrorDetail("failed to patch nullclaw runtime profile config"); + return error.ConfigGenerationFailed; + }; + _ = nullclaw_web_channel.ensureNullclawWebChannelConfig( allocator, p, @@ -728,6 +751,16 @@ const ProviderSelection = struct { model: []const u8, }; +const NullclawRuntimeProfile = struct { + requested: bool = false, + stateless: bool = false, + gateway_require_pairing: bool = true, + gateway_max_body_size_bytes: i64 = min_gateway_body_size, + gateway_request_timeout_secs: i64 = min_gateway_timeout_secs, + a2a_enabled: bool = true, + a2a_multi_modal: bool = true, +}; + const CustomProvidersRewrite = struct { custom_providers: []CustomProvider, selections: []ProviderSelection, @@ -787,6 +820,28 @@ fn stringField(obj: *std.json.ObjectMap, key: []const u8) []const u8 { }; } +fn boolField(obj: *std.json.ObjectMap, key: []const u8) ?bool { + return switch (obj.get(key) orelse .null) { + .bool => |value| value, + .string => |value| if (std.ascii.eqlIgnoreCase(value, "true")) + true + else if (std.ascii.eqlIgnoreCase(value, "false")) + false + else + null, + else => null, + }; +} + +fn integerField(obj: *std.json.ObjectMap, key: []const u8) ?i64 { + return switch (obj.get(key) orelse .null) { + .integer => |value| value, + .number_string => |value| std.fmt.parseInt(i64, value, 10) catch null, + .string => |value| std.fmt.parseInt(i64, value, 10) catch null, + else => null, + }; +} + fn appendProviderSelection( allocator: std.mem.Allocator, selections: *std.array_list.Managed(ProviderSelection), @@ -921,6 +976,111 @@ fn extractCustomProviders(allocator: std.mem.Allocator, json: []const u8) !?Cust }; } +fn parseNullclawRuntimeProfile( + allocator: std.mem.Allocator, + component: []const u8, + answers_json: []const u8, +) NullclawRuntimeProfile { + if (!std.mem.eql(u8, component, "nullclaw")) return .{}; + + var parsed = std.json.parseFromSlice(std.json.Value, allocator, answers_json, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch return .{}; + defer parsed.deinit(); + if (parsed.value != .object) return .{}; + + const root = &parsed.value.object; + var profile: NullclawRuntimeProfile = .{}; + + const profile_name = blk: { + const nullhub_profile = stringField(root, "nullhub_profile"); + if (nullhub_profile.len > 0) break :blk nullhub_profile; + const runtime_profile = stringField(root, "nullhub_runtime_profile"); + if (runtime_profile.len > 0) break :blk runtime_profile; + break :blk stringField(root, "runtime_profile"); + }; + if (std.ascii.eqlIgnoreCase(profile_name, "stateless") or std.ascii.eqlIgnoreCase(profile_name, "minimal_none")) { + profile.requested = true; + profile.stateless = true; + } + if (boolField(root, "stateless") == true) { + profile.requested = true; + profile.stateless = true; + } + + if (std.ascii.eqlIgnoreCase(stringField(root, "memory_profile"), "minimal_none") or + std.ascii.eqlIgnoreCase(stringField(root, "memory_backend"), "none") or + boolField(root, "memory_auto_save") == false) + { + profile.requested = true; + profile.stateless = true; + } + + if (root.get("memory")) |memory_value| { + if (memory_value == .object) { + var memory = memory_value.object; + if (std.ascii.eqlIgnoreCase(stringField(&memory, "profile"), "minimal_none") or + std.ascii.eqlIgnoreCase(stringField(&memory, "backend"), "none") or + boolField(&memory, "auto_save") == false) + { + profile.requested = true; + profile.stateless = true; + } + } + } + + if (boolField(root, "gateway_require_pairing")) |value| { + profile.requested = true; + profile.gateway_require_pairing = value; + } + if (integerField(root, "gateway_max_body_size_bytes")) |value| { + profile.requested = true; + profile.gateway_max_body_size_bytes = @max(value, min_gateway_body_size); + } + if (integerField(root, "gateway_request_timeout_secs")) |value| { + profile.requested = true; + profile.gateway_request_timeout_secs = @max(value, min_gateway_timeout_secs); + } + if (boolField(root, "a2a_enabled")) |value| { + profile.requested = true; + profile.a2a_enabled = value; + } + if (boolField(root, "a2a_multi_modal")) |value| { + profile.requested = true; + profile.a2a_multi_modal = value; + } + + return profile; +} + +fn stripNullhubRuntimeProfileHints(allocator: std.mem.Allocator, component: []const u8, answers_json: []const u8, profile: NullclawRuntimeProfile) ![]const u8 { + if (!std.mem.eql(u8, component, "nullclaw") or !profile.requested) return answers_json; + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, answers_json, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + if (parsed.value != .object) return answers_json; + + var root = &parsed.value.object; + _ = root.orderedRemove("nullhub_profile"); + _ = root.orderedRemove("nullhub_runtime_profile"); + _ = root.orderedRemove("runtime_profile"); + _ = root.orderedRemove("stateless"); + _ = root.orderedRemove("memory_profile"); + _ = root.orderedRemove("memory_backend"); + _ = root.orderedRemove("memory_auto_save"); + _ = root.orderedRemove("gateway_require_pairing"); + _ = root.orderedRemove("gateway_max_body_size_bytes"); + _ = root.orderedRemove("gateway_request_timeout_secs"); + _ = root.orderedRemove("a2a_enabled"); + _ = root.orderedRemove("a2a_multi_modal"); + + return std.json.Stringify.valueAlloc(allocator, parsed.value, .{}); +} + fn selectionContainsProvider(selections: []const ProviderSelection, provider: []const u8) bool { for (selections) |selection| { if (std.mem.eql(u8, selection.provider, provider)) return true; @@ -1038,6 +1198,26 @@ fn patchCustomProvidersIntoConfig( try out.writeAll("\n"); } +fn patchNullclawRuntimeProfileIntoConfig( + allocator: std.mem.Allocator, + p: paths_mod.Paths, + component: []const u8, + name: []const u8, + profile: NullclawRuntimeProfile, +) !void { + if (!std.mem.eql(u8, component, "nullclaw") or !profile.requested) return; + + var access = try nullclaw_gateway_config.ensureConfig(allocator, p, component, name, .{ + .require_pairing = profile.gateway_require_pairing, + .max_body_size_bytes = profile.gateway_max_body_size_bytes, + .request_timeout_secs = profile.gateway_request_timeout_secs, + .a2a_enabled = profile.a2a_enabled, + .a2a_multi_modal = profile.a2a_multi_modal, + .stateless_memory = profile.stateless, + }); + defer access.deinit(allocator); +} + fn ensureObjectInMap( allocator: std.mem.Allocator, obj: *std.json.ObjectMap, @@ -1765,3 +1945,88 @@ test "patchCustomProvidersIntoConfig restores primary custom and keeps standard try std.testing.expectEqual(@as(usize, 1), fallbacks.len); try std.testing.expectEqualStrings("openrouter", fallbacks[0].string); } + +test "stripNullhubRuntimeProfileHints removes nullhub-only fields before component config generation" { + const allocator = std.testing.allocator; + const body = + \\{"instance_name":"nullhat","provider":"openrouter","nullhub_profile":"stateless","memory_backend":"none","gateway_max_body_size_bytes":33554432,"a2a_multi_modal":true} + ; + const profile = parseNullclawRuntimeProfile(allocator, "nullclaw", body); + try std.testing.expect(profile.requested); + try std.testing.expect(profile.stateless); + try std.testing.expect(profile.gateway_max_body_size_bytes >= 33_554_432); + + const stripped = try stripNullhubRuntimeProfileHints(allocator, "nullclaw", body, profile); + defer allocator.free(stripped); + try std.testing.expect(std.mem.indexOf(u8, stripped, "nullhub_profile") == null); + try std.testing.expect(std.mem.indexOf(u8, stripped, "memory_backend") == null); + try std.testing.expect(std.mem.indexOf(u8, stripped, "gateway_max_body_size_bytes") == null); + try std.testing.expect(std.mem.indexOf(u8, stripped, "\"provider\":\"openrouter\"") != null); +} + +test "patchNullclawRuntimeProfileIntoConfig prepares stateless gateway config for NullHat-style instances" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + try fixture.paths.ensureDirs(); + + const comp_dir = try std.fs.path.join(allocator, &.{ fixture.paths.root, "instances", "nullclaw" }); + defer allocator.free(comp_dir); + std_compat.fs.makeDirAbsolute(comp_dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + const inst_dir = try fixture.paths.instanceDir(allocator, "nullclaw", "nullhat"); + defer allocator.free(inst_dir); + std_compat.fs.makeDirAbsolute(inst_dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + + const config_path = try fixture.paths.instanceConfig(allocator, "nullclaw", "nullhat"); + defer allocator.free(config_path); + try writeFile(config_path, + \\{"gateway":{"port":43123,"max_body_size_bytes":1024},"a2a":{"enabled":false},"memory":{"profile":"hybrid_keyword","backend":"hybrid","auto_save":true}} + ); + + const profile = parseNullclawRuntimeProfile(allocator, "nullclaw", + \\{"nullhub_profile":"stateless"} + ); + try patchNullclawRuntimeProfileIntoConfig(allocator, fixture.paths, "nullclaw", "nullhat", profile); + + const file = try std_compat.fs.openFileAbsolute(config_path, .{}); + defer file.close(); + const contents = try file.readToEndAlloc(allocator, 1024 * 1024); + defer allocator.free(contents); + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, contents, .{ .allocate = .alloc_always }); + defer parsed.deinit(); + + const root = parsed.value.object; + const gateway = root.get("gateway").?.object; + const a2a = root.get("a2a").?.object; + const memory = root.get("memory").?.object; + try std.testing.expect(gateway.get("require_pairing").?.bool); + try std.testing.expect(gateway.get("max_body_size_bytes").?.integer >= min_gateway_body_size); + try std.testing.expect(gateway.get("request_timeout_secs").?.integer >= min_gateway_timeout_secs); + try std.testing.expect(a2a.get("enabled").?.bool); + try std.testing.expect(a2a.get("multi_modal").?.bool); + try std.testing.expectEqualStrings("minimal_none", memory.get("profile").?.string); + try std.testing.expectEqualStrings("none", memory.get("backend").?.string); + try std.testing.expect(!memory.get("auto_save").?.bool); + + const token_path = try nullclaw_gateway_config.gatewayTokenPath(allocator, fixture.paths, "nullclaw", "nullhat"); + defer allocator.free(token_path); + const token_file = try std_compat.fs.openFileAbsolute(token_path, .{}); + defer token_file.close(); + const token_bytes = try token_file.readToEndAlloc(allocator, 16 * 1024); + defer allocator.free(token_bytes); + const token = std.mem.trim(u8, token_bytes, " \t\r\n"); + try std.testing.expect(nullclaw_gateway_config.isNullhubGatewayToken(token)); + + const expected_hash = try nullclaw_gateway_config.hashGatewayTokenAlloc(allocator, token); + defer allocator.free(expected_hash); + const paired_tokens = gateway.get("paired_tokens").?.array.items; + try std.testing.expectEqual(@as(usize, 1), paired_tokens.len); + try std.testing.expectEqualStrings(expected_hash, paired_tokens[0].string); + try std.testing.expect(!nullclaw_gateway_config.isNullhubGatewayToken(paired_tokens[0].string)); +} diff --git a/src/installer/registry.zig b/src/installer/registry.zig index 68ae0a6..192a080 100644 --- a/src/installer/registry.zig +++ b/src/installer/registry.zig @@ -56,7 +56,7 @@ pub const known_components = [_]KnownComponent{ .{ .name = "nullwatch", .display_name = "NullWatch", - .description = "Headless observability, tracing, evals, and run intelligence for lightweight agent infrastructure.", + .description = "Headless tracing, evals, and run intelligence for lightweight agent infrastructure.", .repo = "nullclaw/nullwatch", .default_launch_command = "serve", .default_port = 7710, @@ -73,19 +73,6 @@ pub fn findKnownComponent(name: []const u8) ?KnownComponent { return null; } -/// NullBoiler and NullTickets expose long-lived API services as the default -/// process. Their manifests historically named the binary as the launch -/// command; NullHub stores the service mode as `server` so process supervision -/// can use HTTP health checks without passing a component-name argument. -pub fn normalizeLaunchCommand(component: []const u8, command: []const u8) []const u8 { - if ((std.mem.eql(u8, component, "nullboiler") or std.mem.eql(u8, component, "nulltickets")) and - (std.mem.eql(u8, command, component) or std.mem.eql(u8, command, "serve"))) - { - return "server"; - } - return command; -} - // ─── URL builders ──────────────────────────────────────────────────────────── /// Build the GitHub API URL for the latest release of a repository. @@ -278,12 +265,6 @@ test "findKnownComponent returns null for unknown" { try std.testing.expect(findKnownComponent("nonexistent") == null); } -test "normalizeLaunchCommand maps service component binary names to server mode" { - try std.testing.expectEqualStrings("server", normalizeLaunchCommand("nullboiler", "nullboiler")); - try std.testing.expectEqualStrings("server", normalizeLaunchCommand("nulltickets", "nulltickets")); - try std.testing.expectEqualStrings("gateway", normalizeLaunchCommand("nullclaw", "gateway")); -} - test "buildReleasesUrl" { const allocator = std.testing.allocator; const url = try buildReleasesUrl(allocator, "nullclaw/nullclaw"); diff --git a/src/integration_tests.zig b/src/integration_tests.zig index 66a7e31..adcfbec 100644 --- a/src/integration_tests.zig +++ b/src/integration_tests.zig @@ -619,11 +619,11 @@ test "integration harness covers lifecycle error paths" { } } -test "integration harness covers orchestration proxy not configured" { +test "integration harness covers NullBoiler proxy not configured" { var server = try IntegrationServer.start(std.testing.allocator); defer server.deinit(); - const resp = try server.fetch(.{ .path = "/api/orchestration/runs" }); + const resp = try server.fetch(.{ .path = "/api/nullboiler/runs" }); defer resp.deinit(std.testing.allocator); try std.testing.expectEqual(std.http.Status.service_unavailable, resp.status); try std.testing.expect(std.mem.indexOf(u8, resp.body, "NullBoiler not configured") != null); diff --git a/src/main.zig b/src/main.zig index fd2ddc3..6488dd9 100644 --- a/src/main.zig +++ b/src/main.zig @@ -118,7 +118,6 @@ pub fn main(init: std.process.Init) !void { std.process.exit(1); }, .uninstall => |opts| runUninstallCommand(allocator, opts), - .add_source => |opts| std.debug.print("add-source {s} (not yet implemented)\n", .{opts.repo}), .report => |opts| report_cli.run(allocator, opts) catch |err| { const any_err: anyerror = err; switch (any_err) { diff --git a/src/managed_skills.zig b/src/managed_skills.zig index 3fc9940..5dec054 100644 --- a/src/managed_skills.zig +++ b/src/managed_skills.zig @@ -32,7 +32,7 @@ const bundled_skills = [_]BundledSkill{ .entry = .{ .name = "nullhub-admin", .version = "0.1.0", - .description = "Teach managed nullclaw agents to discover NullHub routes first and then use nullhub api for instance, provider, component, and orchestration tasks.", + .description = "Teach managed nullclaw agents to discover NullHub routes first and then use nullhub api for instance, provider, component, and NullBoiler workflow tasks.", .recommended = true, .install_kind = "bundled", .homepage_url = clawhub_url, diff --git a/src/root.zig b/src/root.zig index aeb7966..63a6a8c 100644 --- a/src/root.zig +++ b/src/root.zig @@ -8,6 +8,7 @@ pub const components_api = @import("api/components.zig"); pub const config_api = @import("api/config.zig"); pub const discovery = @import("discovery.zig"); pub const downloader = @import("installer/downloader.zig"); +pub const durable_file = @import("core/durable_file.zig"); pub const health = @import("supervisor/health.zig"); pub const helpers = @import("api/helpers.zig"); pub const instances_api = @import("api/instances.zig"); @@ -17,7 +18,14 @@ pub const manager = @import("supervisor/manager.zig"); pub const managed_skills = @import("managed_skills.zig"); pub const meta_api = @import("api/meta.zig"); pub const mdns = @import("mdns.zig"); -pub const observability_api = @import("api/observability.zig"); +pub const mission_control = @import("core/mission_control.zig"); +pub const mission_control_api = @import("api/mission_control.zig"); +pub const mission_control_replay = @import("core/mission_control_replay.zig"); +pub const mission_replay_store = @import("core/mission_replay_store.zig"); +pub const nullclaw_gateway_config = @import("core/nullclaw_gateway_config.zig"); +pub const nullboiler_api = @import("api/nullboiler.zig"); +pub const nulltickets_api = @import("api/nulltickets.zig"); +pub const nullwatch_api = @import("api/nullwatch.zig"); pub const orchestrator = @import("installer/orchestrator.zig"); pub const manifest = @import("core/manifest.zig"); pub const paths = @import("core/paths.zig"); @@ -56,6 +64,7 @@ test { _ = config_api; _ = discovery; _ = downloader; + _ = durable_file; _ = health; _ = helpers; _ = instances_api; @@ -65,7 +74,14 @@ test { _ = managed_skills; _ = meta_api; _ = mdns; - _ = observability_api; + _ = mission_control; + _ = mission_control_api; + _ = mission_control_replay; + _ = mission_replay_store; + _ = nullclaw_gateway_config; + _ = nullboiler_api; + _ = nulltickets_api; + _ = nullwatch_api; _ = orchestrator; _ = manifest; _ = paths; diff --git a/src/server.zig b/src/server.zig index 1d7bec5..b950411 100644 --- a/src/server.zig +++ b/src/server.zig @@ -3,6 +3,7 @@ const std_compat = @import("compat"); const net_compat = @import("net_compat.zig"); const auth = @import("auth.zig"); const instances_api = @import("api/instances.zig"); +const proxy_api = @import("api/proxy.zig"); const platform = @import("core/platform.zig"); const components_api = @import("api/components.zig"); const config_api = @import("api/config.zig"); @@ -25,8 +26,11 @@ const providers_api = @import("api/providers.zig"); const channels_api = @import("api/channels.zig"); const usage_api = @import("api/usage.zig"); const report_api = @import("api/report.zig"); -const orchestration_api = @import("api/orchestration.zig"); -const observability_api = @import("api/observability.zig"); +const nullboiler_api = @import("api/nullboiler.zig"); +const nulltickets_api = @import("api/nulltickets.zig"); +const nullwatch_api = @import("api/nullwatch.zig"); +const mission_control_api = @import("api/mission_control.zig"); +const mission_core = @import("core/mission_control.zig"); const launch_args_mod = @import("core/launch_args.zig"); const ui_modules = @import("installer/ui_modules.zig"); const orchestrator = @import("installer/orchestrator.zig"); @@ -35,7 +39,64 @@ const ui_assets = @import("ui_assets"); const version = @import("version.zig"); const test_helpers = @import("test_helpers.zig"); -const max_request_size: usize = 65_536; +const default_max_request_size: usize = 64 * 1024; +const gateway_max_request_size: usize = 25 * 1024 * 1024; +const initial_request_buffer_size: usize = 64 * 1024; +const mission_workflow_evidence_ttl_ms: i64 = 5000; +const mission_workflow_scan_limit: usize = 50; +const mission_workflow_response_max_bytes: usize = 2 * 1024 * 1024; + +const MissionWorkflowEvidenceCache = struct { + mutex: std_compat.sync.Mutex = .{}, + arena: ?std.heap.ArenaAllocator = null, + key: []const u8 = "", + checked_at_ms: i64 = 0, + evidence: mission_core.WorkflowEvidence = mission_core.workflowEvidenceUnavailable("not_checked"), + + fn deinit(self: *MissionWorkflowEvidenceCache) void { + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.arena) |*arena| arena.deinit(); + self.arena = null; + self.key = ""; + self.checked_at_ms = 0; + self.evidence = mission_core.workflowEvidenceUnavailable("not_checked"); + } + + fn cloneFresh(self: *MissionWorkflowEvidenceCache, allocator: std.mem.Allocator, key: []const u8, now_ms: i64) ?mission_core.WorkflowEvidence { + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.arena == null) return null; + if (!std.mem.eql(u8, self.key, key)) return null; + if (now_ms - self.checked_at_ms > mission_workflow_evidence_ttl_ms) return null; + return mission_core.cloneWorkflowEvidence(allocator, self.evidence) catch + mission_core.workflowEvidenceUnavailable("evidence_clone_failed"); + } + + fn replaceAndClone( + self: *MissionWorkflowEvidenceCache, + allocator: std.mem.Allocator, + arena: std.heap.ArenaAllocator, + key: []const u8, + checked_at_ms: i64, + evidence: mission_core.WorkflowEvidence, + ) mission_core.WorkflowEvidence { + self.mutex.lock(); + defer self.mutex.unlock(); + if (self.arena) |*old| old.deinit(); + self.arena = arena; + self.key = key; + self.checked_at_ms = checked_at_ms; + self.evidence = evidence; + return mission_core.cloneWorkflowEvidence(allocator, self.evidence) catch + mission_core.workflowEvidenceUnavailable("evidence_clone_failed"); + } +}; + +const MissionRunCandidate = struct { + run: mission_core.WorkflowEvidenceRun, + checkpoints: []const mission_core.WorkflowEvidenceCheckpoint, +}; pub const Server = struct { allocator: std.mem.Allocator, @@ -49,6 +110,8 @@ pub const Server = struct { paths: paths_mod.Paths, manager: *manager_mod.Manager, mutex: *std_compat.sync.Mutex, + mission_control: mission_control_api.RuntimeStore = .{}, + mission_workflow_evidence_cache: MissionWorkflowEvidenceCache = .{}, start_time: i64, pub fn init(allocator: std.mem.Allocator, host: []const u8, port: u16, manager: *manager_mod.Manager, mutex: *std_compat.sync.Mutex) !Server { @@ -95,6 +158,7 @@ pub const Server = struct { } pub fn deinit(self: *Server) void { + self.mission_workflow_evidence_cache.deinit(); self.state.deinit(); self.allocator.destroy(self.state); self.paths.deinit(self.allocator); @@ -159,23 +223,12 @@ pub const Server = struct { }; defer self.allocator.free(desired_binary); - const launch_mode = normalizedLaunchModeForRestore(component, entry.launch_mode); - var desired_launch = launch_args_mod.resolve(self.allocator, launch_mode, entry.verbose) catch { + var desired_launch = launch_args_mod.resolve(self.allocator, entry.launch_mode, entry.verbose) catch { self.terminatePersistedRuntime(&runtime, component, name); return false; }; defer desired_launch.deinit(); - if (!std.mem.eql(u8, launch_mode, entry.launch_mode)) { - _ = self.state.updateInstance(component, name, .{ - .version = entry.version, - .auto_start = entry.auto_start, - .launch_mode = launch_mode, - .verbose = entry.verbose, - }) catch {}; - self.state.save() catch {}; - } - if (!persistedMatchesDesired(runtime, desired_binary, desired_launch.primary_command, desired_launch.argv)) { self.terminatePersistedRuntime(&runtime, component, name); return false; @@ -186,18 +239,6 @@ pub const Server = struct { return restored; } - fn normalizedLaunchModeForRestore(component: []const u8, launch_mode: []const u8) []const u8 { - const known = registry.findKnownComponent(component) orelse return launch_mode; - const normalized = registry.normalizeLaunchCommand(component, launch_mode); - if (!std.mem.eql(u8, known.default_launch_command, "gateway") and std.mem.eql(u8, normalized, "gateway")) { - return known.default_launch_command; - } - if (std.mem.eql(u8, component, "nullwatch") and std.mem.eql(u8, normalized, "nullwatch")) { - return known.default_launch_command; - } - return normalized; - } - fn terminatePersistedRuntime( self: *Server, runtime: *runtime_state_mod.PersistedRuntime, @@ -459,7 +500,7 @@ pub const Server = struct { } fn handleConnection(self: *Server, conn: std_compat.net.Server.Connection, alloc: std.mem.Allocator) !void { - var req_buf: [max_request_size]u8 = undefined; + var req_buf: [initial_request_buffer_size]u8 = undefined; const n = net_compat.streamRead(conn.stream, &req_buf) catch return; if (n == 0) return; const raw = req_buf[0..n]; @@ -490,9 +531,6 @@ pub const Server = struct { return; } - // Read remaining body if Content-Length indicates more data - const body = readBody(raw, n, conn.stream, alloc) catch return; - // Handle OPTIONS preflight if (std.mem.eql(u8, method, "OPTIONS")) { try sendResponse(conn.stream, .{ @@ -515,6 +553,63 @@ pub const Server = struct { } } + // Read remaining body only after origin and auth are accepted. + const body = readBody(raw, n, conn.stream, alloc, maxRequestBodySize(target)) catch |err| { + const response = switch (err) { + error.RequestTooLarge => Response{ + .status = "413 Request Entity Too Large", + .content_type = "application/json", + .body = "{\"error\":\"request body too large\"}", + }, + error.IncompleteBody, error.InvalidContentLength => Response{ + .status = "400 Bad Request", + .content_type = "application/json", + .body = "{\"error\":\"invalid request body\"}", + }, + else => return err, + }; + try sendResponse(conn.stream, response, raw, self.host, self.port, extra_origins); + return; + }; + + if (instances_api.isGatewayProxyPath(target)) { + const prepared = blk: { + self.mutex.lock(); + defer self.mutex.unlock(); + break :blk instances_api.prepareGatewayProxy(alloc, self.state, self.manager, self.paths, method, target, body); + }; + switch (prepared) { + .no_match => {}, + .response => |response| { + try sendResponse(conn.stream, .{ .status = response.status, .content_type = response.content_type, .body = response.body }, raw, self.host, self.port, extra_origins); + return; + }, + .upstream => |upstream| { + defer upstream.deinit(alloc); + const cors_headers = try buildCorsHeaders(alloc, raw, self.host, self.port, extra_origins); + defer alloc.free(cors_headers); + const base_url = try std.fmt.allocPrint(alloc, "http://127.0.0.1:{d}", .{upstream.port}); + defer alloc.free(base_url); + const proxy_options = proxy_api.ForwardOptions{ + .method = method, + .base_url = base_url, + .path = upstream.upstream_path, + .body = upstream.body, + .bearer_token = upstream.token, + .accept = if (upstream.event_stream) "text/event-stream" else null, + .unreachable_body = "{\"error\":\"nullclaw gateway unreachable\"}", + }; + if (upstream.event_stream) { + try proxy_api.forwardStream(alloc, proxy_options, conn.stream, cors_headers); + } else { + const proxied = proxy_api.forward(alloc, proxy_options); + try sendResponse(conn.stream, .{ .status = proxied.status, .content_type = proxied.content_type, .body = proxied.body }, raw, self.host, self.port, extra_origins); + } + return; + }, + } + } + // Route dispatch (lock mutex so supervisor thread doesn't race) const response = if (routeWithoutServerMutex(target)) self.route(alloc, method, target, body) @@ -583,10 +678,12 @@ pub const Server = struct { } const ManagedBackendConfig = struct { + name: []u8, url: []u8, token: ?[]u8 = null, fn deinit(self: *ManagedBackendConfig, allocator: std.mem.Allocator) void { + allocator.free(self.name); allocator.free(self.url); if (self.token) |token| allocator.free(token); self.* = undefined; @@ -617,14 +714,14 @@ pub const Server = struct { if (requested_name) |wanted| { for (configs) |cfg| { if (std.mem.eql(u8, cfg.name, wanted)) { - return managedBackendFromConfig(allocator, cfg.port, cfg.api_token); + return managedBackendFromConfig(allocator, cfg.name, cfg.port, cfg.api_token); } } return null; } const selected = self.selectManagedBackendIndex(component, configs); - return managedBackendFromConfig(allocator, configs[selected].port, configs[selected].api_token); + return managedBackendFromConfig(allocator, configs[selected].name, configs[selected].port, configs[selected].api_token); } fn selectManagedBackendIndex(self: *Server, component: []const u8, configs: anytype) usize { @@ -637,18 +734,106 @@ pub const Server = struct { return fallback; } - fn managedBackendFromConfig(allocator: std.mem.Allocator, port: u16, token: ?[]const u8) ?ManagedBackendConfig { - const url = std.fmt.allocPrint(allocator, "http://127.0.0.1:{d}", .{port}) catch return null; + fn managedBackendFromConfig(allocator: std.mem.Allocator, name: []const u8, port: u16, token: ?[]const u8) ?ManagedBackendConfig { + const owned_name = allocator.dupe(u8, name) catch return null; + const url = std.fmt.allocPrint(allocator, "http://127.0.0.1:{d}", .{port}) catch { + allocator.free(owned_name); + return null; + }; const owned_token = if (token) |value| allocator.dupe(u8, value) catch { + allocator.free(owned_name); allocator.free(url); return null; } else null; return .{ + .name = owned_name, .url = url, .token = owned_token, }; } + fn ownedManagedBackend(allocator: std.mem.Allocator, name: []const u8, url: []const u8, token: ?[]const u8) !ManagedBackendConfig { + const owned_name = try allocator.dupe(u8, name); + errdefer allocator.free(owned_name); + const owned_url = try allocator.dupe(u8, url); + errdefer allocator.free(owned_url); + const owned_token = if (token) |value| try allocator.dupe(u8, value) else null; + errdefer if (owned_token) |value| allocator.free(value); + return .{ + .name = owned_name, + .url = owned_url, + .token = owned_token, + }; + } + + fn deinitManagedBackendConfigs(allocator: std.mem.Allocator, configs: []ManagedBackendConfig) void { + for (configs) |*cfg| cfg.deinit(allocator); + allocator.free(configs); + } + + fn appendUniqueManagedBackend( + allocator: std.mem.Allocator, + list: *std.ArrayListUnmanaged(ManagedBackendConfig), + backend: ManagedBackendConfig, + ) !void { + var owned = backend; + errdefer owned.deinit(allocator); + for (list.items) |item| { + if (std.mem.eql(u8, item.url, owned.url)) { + owned.deinit(allocator); + return; + } + } + try list.append(allocator, owned); + } + + fn appendManagedNullBoilerBackends(self: *Server, allocator: std.mem.Allocator, list: *std.ArrayListUnmanaged(ManagedBackendConfig)) !void { + self.mutex.lock(); + defer self.mutex.unlock(); + + const configs = try integration_mod.listNullBoilers(allocator, self.state, self.paths); + defer integration_mod.deinitNullBoilerConfigs(allocator, configs); + + const before_running = list.items.len; + try self.appendManagedBackendPass(allocator, list, "nullboiler", configs, true); + if (list.items.len == before_running) { + try self.appendManagedBackendPass(allocator, list, "nullboiler", configs, false); + } + } + + fn appendManagedBackendPass( + self: *Server, + allocator: std.mem.Allocator, + list: *std.ArrayListUnmanaged(ManagedBackendConfig), + component: []const u8, + configs: anytype, + running_pass: bool, + ) !void { + for (configs) |cfg| { + const status = self.manager.getStatus(component, cfg.name); + const running = if (status) |value| value.status == .running else false; + if (running != running_pass) continue; + const backend = managedBackendFromConfig(allocator, cfg.name, cfg.port, cfg.api_token) orelse return error.OutOfMemory; + try appendUniqueManagedBackend(allocator, list, backend); + } + } + + fn missionWorkflowBackends(self: *Server, allocator: std.mem.Allocator) ![]ManagedBackendConfig { + var list: std.ArrayListUnmanaged(ManagedBackendConfig) = .empty; + errdefer { + for (list.items) |*cfg| cfg.deinit(allocator); + list.deinit(allocator); + } + defer list.deinit(allocator); + + if (self.getBoilerUrl()) |url| { + const env_backend = try ownedManagedBackend(allocator, "env", url, self.getBoilerToken()); + try appendUniqueManagedBackend(allocator, &list, env_backend); + } + try self.appendManagedNullBoilerBackends(allocator, &list); + return try list.toOwnedSlice(allocator); + } + fn shouldResolveManagedBackend(env_url: ?[]const u8, requested_name: ?[]const u8) bool { return requested_name != null or env_url == null; } @@ -663,6 +848,157 @@ pub const Server = struct { return env_token orelse if (managed) |cfg| cfg.token else null; } + fn resolveMissionWorkflowEvidence(self: *Server, request_allocator: std.mem.Allocator, refs: mission_core.WorkflowEvidenceRefs) mission_core.WorkflowEvidence { + const now_ms = std_compat.time.milliTimestamp(); + const backends = self.missionWorkflowBackends(request_allocator) catch + return mission_core.workflowEvidenceUnavailable("nullboiler_backend_discovery_failed"); + defer deinitManagedBackendConfigs(request_allocator, backends); + + const cache_key = missionWorkflowEvidenceCacheKey(request_allocator, refs, backends) catch + return mission_core.workflowEvidenceUnavailable("cache_key_allocation_failed"); + defer request_allocator.free(cache_key); + + if (self.mission_workflow_evidence_cache.cloneFresh(request_allocator, cache_key, now_ms)) |cached| return cached; + + var arena = std.heap.ArenaAllocator.init(self.allocator); + errdefer arena.deinit(); + const allocator = arena.allocator(); + const owned_key = allocator.dupe(u8, cache_key) catch + return mission_core.workflowEvidenceUnavailable("cache_key_allocation_failed"); + const evidence = self.loadMissionWorkflowEvidence(allocator, refs, backends); + return self.mission_workflow_evidence_cache.replaceAndClone(request_allocator, arena, owned_key, now_ms, evidence); + } + + fn loadMissionWorkflowEvidence( + self: *Server, + allocator: std.mem.Allocator, + refs: mission_core.WorkflowEvidenceRefs, + backends: []const ManagedBackendConfig, + ) mission_core.WorkflowEvidence { + if (backends.len == 0) return missionWorkflowEvidenceStatus("not_configured", "nullboiler_not_configured", 0); + + var available: ?mission_core.WorkflowEvidence = null; + var best_not_found: ?mission_core.WorkflowEvidence = null; + var best_unavailable: ?mission_core.WorkflowEvidence = null; + var scanned_run_count: usize = 0; + + for (backends) |backend| { + const evidence = self.loadMissionWorkflowEvidenceFromBackend(allocator, refs, backend); + scanned_run_count += evidence.scanned_run_count; + if (std.mem.eql(u8, evidence.status, "available")) { + if (available != null) { + return missionWorkflowEvidenceStatus("ambiguous", "workflow_evidence_matched_multiple_backends", scanned_run_count); + } + available = evidence; + continue; + } + if (std.mem.eql(u8, evidence.status, "ambiguous")) return evidence; + if (std.mem.eql(u8, evidence.status, "not_found")) { + if (best_not_found == null or evidence.scanned_run_count > best_not_found.?.scanned_run_count) best_not_found = evidence; + continue; + } + if (best_unavailable == null) best_unavailable = evidence; + } + + if (available) |evidence| return evidence; + if (best_not_found) |evidence| return evidence; + if (best_unavailable) |evidence| return evidence; + return missionWorkflowEvidenceStatus("not_configured", "nullboiler_not_configured", 0); + } + + fn loadMissionWorkflowEvidenceFromBackend( + self: *Server, + allocator: std.mem.Allocator, + refs: mission_core.WorkflowEvidenceRefs, + backend: ManagedBackendConfig, + ) mission_core.WorkflowEvidence { + var scratch_arena = std.heap.ArenaAllocator.init(self.allocator); + defer scratch_arena.deinit(); + const scratch_allocator = scratch_arena.allocator(); + + const runs_resp = proxy_api.forward(scratch_allocator, .{ + .method = "GET", + .base_url = backend.url, + .path = "/runs?limit=50", + .body = "", + .bearer_token = backend.token, + .unreachable_body = "{\"error\":\"NullBoiler unreachable\"}", + .max_response_bytes = mission_workflow_response_max_bytes, + }); + if (!isSuccessStatus(runs_resp.status)) { + return missionWorkflowEvidenceWithBackendName(allocator, missionWorkflowEvidenceStatus("unavailable", "nullboiler_runs_unavailable", 0), backend.name); + } + + const parsed_runs = std.json.parseFromSlice(std.json.Value, scratch_allocator, runs_resp.body, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch return missionWorkflowEvidenceWithBackendName(allocator, missionWorkflowEvidenceStatus("schema_mismatch", "invalid_runs_payload", 0), backend.name); + defer parsed_runs.deinit(); + + const items = missionWorkflowRunItems(parsed_runs.value) orelse + return missionWorkflowEvidenceWithBackendName(allocator, missionWorkflowEvidenceStatus("schema_mismatch", "missing_runs_items", 0), backend.name); + + var candidates: std.ArrayListUnmanaged(MissionRunCandidate) = .empty; + for (items[0..@min(items.len, mission_workflow_scan_limit)]) |item| { + const run_id = jsonStringField(item, "id"); + if (run_id.len == 0) continue; + + const loaded_checkpoints: ?[]const mission_core.WorkflowEvidenceCheckpoint = + self.loadMissionRunCheckpoints(allocator, scratch_allocator, backend.url, backend.token, run_id) catch null; + const checkpoints = loaded_checkpoints orelse &.{}; + const evidence_run = mission_core.WorkflowEvidenceRun{ + .run_id = allocator.dupe(u8, run_id) catch + return missionWorkflowEvidenceWithBackendName(allocator, missionWorkflowEvidenceStatus("unavailable", "run_id_allocation_failed", candidates.items.len), backend.name), + .status = allocator.dupe(u8, jsonStringFieldOr(item, "status", "unknown")) catch + return missionWorkflowEvidenceWithBackendName(allocator, missionWorkflowEvidenceStatus("unavailable", "run_status_allocation_failed", candidates.items.len), backend.name), + .created_at_ms = jsonIntField(item, "created_at_ms"), + .updated_at_ms = jsonIntField(item, "updated_at_ms"), + .checkpoint_count = if (loaded_checkpoints != null) checkpoints.len else null, + }; + candidates.append(allocator, .{ .run = evidence_run, .checkpoints = checkpoints }) catch + return missionWorkflowEvidenceWithBackendName(allocator, missionWorkflowEvidenceStatus("unavailable", "candidate_allocation_failed", candidates.items.len), backend.name); + } + + return missionWorkflowEvidenceWithBackendName(allocator, selectMissionWorkflowEvidence(refs, candidates.items), backend.name); + } + + fn loadMissionRunCheckpoints( + self: *Server, + allocator: std.mem.Allocator, + scratch_allocator: std.mem.Allocator, + base_url: []const u8, + token: ?[]const u8, + run_id: []const u8, + ) ![]const mission_core.WorkflowEvidenceCheckpoint { + _ = self; + const path = try missionRunCheckpointsPath(scratch_allocator, run_id); + const resp = proxy_api.forward(scratch_allocator, .{ + .method = "GET", + .base_url = base_url, + .path = path, + .body = "", + .bearer_token = token, + .unreachable_body = "{\"error\":\"NullBoiler unreachable\"}", + .max_response_bytes = mission_workflow_response_max_bytes, + }); + if (!isSuccessStatus(resp.status)) return error.CheckpointsUnavailable; + + const parsed = std.json.parseFromSlice(std.json.Value, scratch_allocator, resp.body, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch return error.InvalidCheckpointPayload; + defer parsed.deinit(); + if (parsed.value != .array) return error.InvalidCheckpointPayload; + + var checkpoints: std.ArrayListUnmanaged(mission_core.WorkflowEvidenceCheckpoint) = .empty; + for (parsed.value.array.items) |item| { + if (try normalizeMissionCheckpoint(allocator, item, run_id)) |checkpoint| { + try checkpoints.append(allocator, checkpoint); + } + } + return try checkpoints.toOwnedSlice(allocator); + } + const WatchTarget = struct { url: ?[]const u8 = null, url_owned: bool = false, @@ -789,11 +1125,24 @@ pub const Server = struct { return instances_api.isIntegrationPath(target) or instances_api.isTicketsActionPath(target) or logs_api.isLogsPath(target) or - orchestration_api.isProxyPath(target) or - observability_api.isProxyPath(target); + nullboiler_api.isProxyPath(target) or + nulltickets_api.isProxyPath(target) or + nullwatch_api.isProxyPath(target) or + mission_control_api.isPath(target); } fn route(self: *Server, allocator: std.mem.Allocator, method: []const u8, target: []const u8, body: []const u8) Response { + if (mission_control_api.isPath(target)) { + const resp = mission_control_api.handleWithIntegrations(allocator, method, target, &self.mission_control, .{ + .paths = self.paths, + .workflow_evidence_resolver = .{ + .ptr = self, + .resolve = missionWorkflowEvidenceResolver, + }, + }); + return .{ .status = resp.status, .content_type = resp.content_type, .body = resp.body }; + } + if (std.mem.eql(u8, method, "GET")) { if (std.mem.eql(u8, target, "/health")) { return .{ @@ -1362,38 +1711,46 @@ pub const Server = struct { } } - if (orchestration_api.isProxyPath(target)) { + if (nullboiler_api.isProxyPath(target)) { const env_boiler_url = self.getBoilerUrl(); const env_boiler_token = self.getBoilerToken(); - const env_tickets_url = self.getTicketsUrl(); - const env_tickets_token = self.getTicketsToken(); - const requested_boiler = orchestration_api.requestedBoilerInstance(allocator, target) catch null; + const requested_boiler = nullboiler_api.requestedBoilerInstance(allocator, target) catch null; defer if (requested_boiler) |value| allocator.free(value); - const requested_tickets = orchestration_api.requestedTicketsInstance(allocator, target) catch null; - defer if (requested_tickets) |value| allocator.free(value); var managed_boiler = if (shouldResolveManagedBackend(env_boiler_url, requested_boiler)) self.resolveManagedBackend(allocator, "nullboiler", requested_boiler) else null; defer if (managed_boiler) |*cfg| cfg.deinit(allocator); + + const resp = nullboiler_api.handle(allocator, method, target, body, .{ + .boiler_url = selectBackendUrl(env_boiler_url, managed_boiler, requested_boiler), + .boiler_token = selectBackendToken(env_boiler_token, managed_boiler, requested_boiler), + }); + return .{ .status = resp.status, .content_type = resp.content_type, .body = resp.body }; + } + + if (nulltickets_api.isProxyPath(target)) { + const env_tickets_url = self.getTicketsUrl(); + const env_tickets_token = self.getTicketsToken(); + const requested_tickets = nulltickets_api.requestedTicketsInstance(allocator, target) catch null; + defer if (requested_tickets) |value| allocator.free(value); + var managed_tickets = if (shouldResolveManagedBackend(env_tickets_url, requested_tickets)) self.resolveManagedBackend(allocator, "nulltickets", requested_tickets) else null; defer if (managed_tickets) |*cfg| cfg.deinit(allocator); - const resp = orchestration_api.handle(allocator, method, target, body, .{ - .boiler_url = selectBackendUrl(env_boiler_url, managed_boiler, requested_boiler), - .boiler_token = selectBackendToken(env_boiler_token, managed_boiler, requested_boiler), + const resp = nulltickets_api.handle(allocator, method, target, body, .{ .tickets_url = selectBackendUrl(env_tickets_url, managed_tickets, requested_tickets), .tickets_token = selectBackendToken(env_tickets_token, managed_tickets, requested_tickets), }); return .{ .status = resp.status, .content_type = resp.content_type, .body = resp.body }; } - if (observability_api.isProxyPath(target)) { - const selected_watch = observability_api.selectedWatchNameAlloc(allocator, target) catch + if (nullwatch_api.isProxyPath(target)) { + const selected_watch = nullwatch_api.selectedWatchNameAlloc(allocator, target) catch return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" }; defer if (selected_watch) |value| allocator.free(value); @@ -1404,7 +1761,7 @@ pub const Server = struct { }; defer watch_target.deinit(allocator); - const resp = observability_api.handle(allocator, method, target, body, .{ + const resp = nullwatch_api.handle(allocator, method, target, body, .{ .watch_url = watch_target.url, .watch_token = watch_target.token, }); @@ -1435,6 +1792,368 @@ pub const Server = struct { } }; +fn missionWorkflowEvidenceResolver(ptr: *anyopaque, allocator: std.mem.Allocator, refs: mission_core.WorkflowEvidenceRefs) mission_core.WorkflowEvidence { + const server: *Server = @ptrCast(@alignCast(ptr)); + return server.resolveMissionWorkflowEvidence(allocator, refs); +} + +fn missionWorkflowEvidenceCacheKey( + allocator: std.mem.Allocator, + refs: mission_core.WorkflowEvidenceRefs, + backends: []const Server.ManagedBackendConfig, +) ![]u8 { + var buf = std.array_list.Managed(u8).init(allocator); + errdefer buf.deinit(); + try buf.appendSlice(refs.scenario_id); + try buf.append('|'); + try buf.appendSlice(refs.mission_id); + try buf.append('|'); + try buf.appendSlice(refs.failed_run_id); + try buf.append('|'); + try buf.appendSlice(refs.recovered_run_id); + try buf.append('|'); + try buf.appendSlice(refs.checkpoint_id); + for (backends) |backend| { + try buf.appendSlice("|backend="); + try buf.appendSlice(backend.name); + try buf.append('@'); + try buf.appendSlice(backend.url); + try buf.append('#'); + try appendTokenFingerprint(&buf, backend.token); + } + return try buf.toOwnedSlice(); +} + +fn appendTokenFingerprint(buf: *std.array_list.Managed(u8), token: ?[]const u8) !void { + const value = token orelse { + try buf.append('-'); + return; + }; + var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(value, &digest, .{}); + const hex = "0123456789abcdef"; + for (digest) |byte| { + try buf.append(hex[byte >> 4]); + try buf.append(hex[byte & 0x0f]); + } +} + +fn missionWorkflowEvidenceStatus(status: []const u8, reason: []const u8, scanned_run_count: usize) mission_core.WorkflowEvidence { + return .{ + .status = status, + .reason = reason, + .scanned_run_count = scanned_run_count, + }; +} + +fn missionWorkflowEvidenceWithBackendName(allocator: std.mem.Allocator, evidence: mission_core.WorkflowEvidence, backend_name: ?[]const u8) mission_core.WorkflowEvidence { + var out = evidence; + if (backend_name) |name| { + out.boiler_instance = allocator.dupe(u8, name) catch null; + } + return out; +} + +fn missionWorkflowRunItems(value: std.json.Value) ?[]std.json.Value { + if (value == .array) return value.array.items; + if (value != .object) return null; + const items = value.object.get("items") orelse value.object.get("runs") orelse return null; + if (items != .array) return null; + return items.array.items; +} + +fn missionRunCheckpointsPath(allocator: std.mem.Allocator, run_id: []const u8) ![]u8 { + var buf = std.array_list.Managed(u8).init(allocator); + errdefer buf.deinit(); + try buf.appendSlice("/runs/"); + try appendUrlPathSegment(&buf, run_id); + try buf.appendSlice("/checkpoints"); + return try buf.toOwnedSlice(); +} + +fn appendUrlPathSegment(buf: *std.array_list.Managed(u8), value: []const u8) !void { + const hex = "0123456789ABCDEF"; + for (value) |byte| { + if (std.ascii.isAlphanumeric(byte) or byte == '-' or byte == '_' or byte == '.' or byte == '~') { + try buf.append(byte); + } else { + try buf.append('%'); + try buf.append(hex[byte >> 4]); + try buf.append(hex[byte & 0x0f]); + } + } +} + +fn isSuccessStatus(status: []const u8) bool { + return status.len >= 1 and status[0] == '2'; +} + +fn normalizeMissionCheckpoint(allocator: std.mem.Allocator, item: std.json.Value, default_run_id: []const u8) !?mission_core.WorkflowEvidenceCheckpoint { + const id = jsonStringField(item, "id"); + if (id.len == 0) return null; + const run_id = jsonStringFieldOr(item, "run_id", default_run_id); + return .{ + .id = try allocator.dupe(u8, id), + .run_id = try allocator.dupe(u8, run_id), + .step_id = try allocator.dupe(u8, firstJsonStringField(item, &.{ "step_id", "step_name", "after_step" })), + .parent_id = try cloneOptionalWorkflowString(allocator, firstJsonOptionalStringField(item, &.{ "parent_id", "parent_checkpoint_id", "forked_from", "source_checkpoint_id" })), + .version = jsonIntField(item, "version"), + .created_at_ms = jsonIntField(item, "created_at_ms"), + .completed_nodes = try jsonStringArrayFields(allocator, item, &.{ "completed_nodes", "completed_nodes_json" }), + .metadata = try jsonCheckpointMetadata(allocator, item), + }; +} + +fn cloneOptionalWorkflowString(allocator: std.mem.Allocator, value: ?[]const u8) !?[]const u8 { + if (value) |text| return try allocator.dupe(u8, text); + return null; +} + +fn selectMissionWorkflowEvidence(refs: mission_core.WorkflowEvidenceRefs, candidates: []const MissionRunCandidate) mission_core.WorkflowEvidence { + const checkpoint_match = findUniqueCheckpointById(candidates, refs.checkpoint_id) catch + return missionWorkflowEvidenceStatus("ambiguous", "checkpoint_id_matched_multiple_runs", candidates.len); + const failed_exact = findCandidateByRunId(candidates, refs.failed_run_id) catch + return missionWorkflowEvidenceStatus("ambiguous", "failed_run_id_matched_multiple_runs", candidates.len); + const recovered_exact = findCandidateByRunId(candidates, refs.recovered_run_id) catch + return missionWorkflowEvidenceStatus("ambiguous", "recovered_run_id_matched_multiple_runs", candidates.len); + const fork_match = if (checkpoint_match.checkpoint) |checkpoint| + findUniqueForkCandidate(candidates, checkpoint.id) catch + return missionWorkflowEvidenceStatus("ambiguous", "fork_parent_matched_multiple_runs", candidates.len) + else + MissionCandidateMatch{}; + + var failed_run: ?mission_core.WorkflowEvidenceRun = null; + if (failed_exact.candidate) |candidate| failed_run = candidate.run; + if (checkpoint_match.candidate) |candidate| { + if (failed_run) |run| { + if (!std.mem.eql(u8, run.run_id, candidate.run.run_id)) { + return missionWorkflowEvidenceStatus("ambiguous", "failed_run_checkpoint_owner_mismatch", candidates.len); + } + } else { + failed_run = candidate.run; + } + } + + var recovered_run: ?mission_core.WorkflowEvidenceRun = null; + if (recovered_exact.candidate) |candidate| recovered_run = candidate.run; + if (fork_match.candidate) |candidate| { + if (recovered_run) |run| { + if (!std.mem.eql(u8, run.run_id, candidate.run.run_id)) { + return missionWorkflowEvidenceStatus("ambiguous", "recovered_run_fork_owner_mismatch", candidates.len); + } + } else { + recovered_run = candidate.run; + } + } + + if (failed_run == null and recovered_run == null and checkpoint_match.checkpoint == null) { + return missionWorkflowEvidenceStatus("not_found", "no_matching_run_or_checkpoint", candidates.len); + } + + return .{ + .status = "available", + .failed_run = failed_run, + .recovered_run = recovered_run, + .checkpoint = checkpoint_match.checkpoint, + .scanned_run_count = candidates.len, + }; +} + +const MissionCandidateMatch = struct { + candidate: ?MissionRunCandidate = null, + checkpoint: ?mission_core.WorkflowEvidenceCheckpoint = null, +}; + +fn findCandidateByRunId(candidates: []const MissionRunCandidate, run_id: []const u8) !MissionCandidateMatch { + if (run_id.len == 0) return .{}; + var match: ?MissionRunCandidate = null; + for (candidates) |candidate| { + if (!std.mem.eql(u8, candidate.run.run_id, run_id)) continue; + if (match != null) return error.Ambiguous; + match = candidate; + } + return .{ .candidate = match }; +} + +fn findUniqueCheckpointById(candidates: []const MissionRunCandidate, checkpoint_id: []const u8) !MissionCandidateMatch { + if (checkpoint_id.len == 0) return .{}; + var found_candidate: ?MissionRunCandidate = null; + var found_checkpoint: ?mission_core.WorkflowEvidenceCheckpoint = null; + for (candidates) |candidate| { + for (candidate.checkpoints) |checkpoint| { + if (!std.mem.eql(u8, checkpoint.id, checkpoint_id)) continue; + if (found_checkpoint != null) return error.Ambiguous; + found_candidate = candidate; + found_checkpoint = checkpoint; + } + } + return .{ .candidate = found_candidate, .checkpoint = found_checkpoint }; +} + +fn findUniqueForkCandidate(candidates: []const MissionRunCandidate, checkpoint_id: []const u8) !MissionCandidateMatch { + if (checkpoint_id.len == 0) return .{}; + var found: ?MissionRunCandidate = null; + for (candidates) |candidate| { + for (candidate.checkpoints) |checkpoint| { + const parent_id = checkpoint.parent_id orelse continue; + if (!std.mem.eql(u8, parent_id, checkpoint_id)) continue; + if (found != null and !std.mem.eql(u8, found.?.run.run_id, candidate.run.run_id)) return error.Ambiguous; + found = candidate; + } + } + return .{ .candidate = found }; +} + +fn jsonStringField(value: std.json.Value, key: []const u8) []const u8 { + if (value != .object) return ""; + return switch (value.object.get(key) orelse .null) { + .string => |string| string, + .number_string => |string| string, + else => "", + }; +} + +fn jsonStringFieldOr(value: std.json.Value, key: []const u8, default: []const u8) []const u8 { + const string = jsonStringField(value, key); + return if (string.len > 0) string else default; +} + +fn firstJsonStringField(value: std.json.Value, keys: []const []const u8) []const u8 { + for (keys) |key| { + const string = jsonStringField(value, key); + if (string.len > 0) return string; + } + return ""; +} + +fn firstJsonOptionalStringField(value: std.json.Value, keys: []const []const u8) ?[]const u8 { + const string = firstJsonStringField(value, keys); + return if (string.len > 0) string else null; +} + +fn jsonOptionalValueField(value: std.json.Value, key: []const u8) ?std.json.Value { + if (value != .object) return null; + const field = value.object.get(key) orelse return null; + if (field == .null) return null; + return field; +} + +fn jsonIntField(value: std.json.Value, key: []const u8) ?i64 { + if (value != .object) return null; + return switch (value.object.get(key) orelse .null) { + .integer => |integer| integer, + .number_string => |string| std.fmt.parseInt(i64, string, 10) catch null, + .string => |string| std.fmt.parseInt(i64, string, 10) catch null, + else => null, + }; +} + +fn jsonStringArrayFields(allocator: std.mem.Allocator, value: std.json.Value, keys: []const []const u8) ![]const []const u8 { + if (value != .object) return &.{}; + for (keys) |key| { + if (value.object.get(key)) |field| { + if (field == .null) continue; + return try jsonStringArrayValue(allocator, field); + } + } + return &.{}; +} + +fn jsonStringArrayValue(allocator: std.mem.Allocator, value: std.json.Value) anyerror![]const []const u8 { + switch (value) { + .array => |array| return try cloneJsonStringArray(allocator, array.items), + .string => |string| return try jsonStringArrayFromEncodedValue(allocator, string), + .number_string => |string| return try jsonStringArrayFromEncodedValue(allocator, string), + else => return &.{}, + } +} + +fn jsonStringArrayFromEncodedValue(allocator: std.mem.Allocator, text: []const u8) anyerror![]const []const u8 { + const trimmed = std.mem.trim(u8, text, " \t\r\n"); + if (trimmed.len == 0) return &.{}; + var parsed = std.json.parseFromSlice(std.json.Value, allocator, trimmed, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return &.{}, + }; + defer parsed.deinit(); + return try jsonStringArrayValue(allocator, parsed.value); +} + +fn cloneJsonStringArray(allocator: std.mem.Allocator, items: []const std.json.Value) ![]const []const u8 { + var list: std.ArrayListUnmanaged([]const u8) = .empty; + errdefer { + for (list.items) |item| allocator.free(item); + list.deinit(allocator); + } + for (items) |item| { + if (item == .string) try list.append(allocator, try allocator.dupe(u8, item.string)); + } + if (list.items.len == 0) { + list.deinit(allocator); + return &.{}; + } + return try list.toOwnedSlice(allocator); +} + +fn jsonCheckpointMetadata(allocator: std.mem.Allocator, value: std.json.Value) !?std.json.Value { + if (jsonOptionalValueField(value, "metadata")) |metadata| { + return try mission_core.cloneJsonValue(allocator, metadata); + } + const encoded = jsonOptionalValueField(value, "metadata_json") orelse return null; + return try cloneJsonEncodedOrRawValue(allocator, encoded); +} + +fn cloneJsonEncodedOrRawValue(allocator: std.mem.Allocator, value: std.json.Value) !?std.json.Value { + return switch (value) { + .string => |string| try parseJsonValueString(allocator, string), + .number_string => |string| try parseJsonValueString(allocator, string), + .null => null, + else => try mission_core.cloneJsonValue(allocator, value), + }; +} + +fn parseJsonValueString(allocator: std.mem.Allocator, text: []const u8) !?std.json.Value { + const trimmed = std.mem.trim(u8, text, " \t\r\n"); + if (trimmed.len == 0) return null; + return std.json.parseFromSliceLeaky(std.json.Value, allocator, trimmed, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return null, + }; +} + +test "normalizeMissionCheckpoint accepts encoded NullBoiler checkpoint fields" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + const payload = + \\{ + \\ "id": "cp-a", + \\ "step_name": "code.build", + \\ "parent_checkpoint_id": "cp-parent", + \\ "completed_nodes_json": "[\"claim\",\"code\"]", + \\ "metadata_json": "{\"source\":\"nullboiler\",\"attempt\":2}" + \\} + ; + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, payload, .{ .allocate = .alloc_always }); + const checkpoint = (try normalizeMissionCheckpoint(allocator, parsed.value, "run-a")).?; + + try std.testing.expectEqualStrings("cp-a", checkpoint.id); + try std.testing.expectEqualStrings("run-a", checkpoint.run_id); + try std.testing.expectEqualStrings("code.build", checkpoint.step_id); + try std.testing.expectEqualStrings("cp-parent", checkpoint.parent_id.?); + try std.testing.expectEqual(@as(usize, 2), checkpoint.completed_nodes.len); + try std.testing.expectEqualStrings("claim", checkpoint.completed_nodes[0]); + try std.testing.expectEqualStrings("code", checkpoint.completed_nodes[1]); + try std.testing.expectEqualStrings("nullboiler", checkpoint.metadata.?.object.get("source").?.string); +} + const Response = struct { status: []const u8, content_type: []const u8, @@ -1445,29 +2164,34 @@ fn jsonResponse(body: []const u8) Response { return .{ .status = "200 OK", .content_type = "application/json", .body = body }; } -fn readBody(raw: []const u8, n: usize, stream: std_compat.net.Stream, alloc: std.mem.Allocator) ![]const u8 { +fn maxRequestBodySize(target: []const u8) usize { + if (instances_api.isGatewayProxyPath(target)) return gateway_max_request_size; + return default_max_request_size; +} + +fn readBody(raw: []const u8, n: usize, stream: std_compat.net.Stream, alloc: std.mem.Allocator, max_body_size: usize) ![]const u8 { if (extractHeader(raw, "Content-Length")) |cl_str| { - const content_length = std.fmt.parseInt(usize, cl_str, 10) catch 0; - if (content_length > 0) { - const header_end_pos = std.mem.indexOf(u8, raw, "\r\n\r\n") orelse return ""; - const body_start = header_end_pos + 4; - const body_received = n - body_start; - if (body_received >= content_length) { - return raw[body_start .. body_start + content_length]; - } - // Need to read more data from the stream - const total_size = body_start + content_length; - if (total_size > max_request_size) return error.RequestTooLarge; - const full_buf = try alloc.alloc(u8, total_size); - @memcpy(full_buf[0..n], raw); - var total_read = n; - while (total_read < total_size) { - const extra = net_compat.streamRead(stream, full_buf[total_read..total_size]) catch break; - if (extra == 0) break; - total_read += extra; - } - return full_buf[body_start..total_read]; + const content_length = std.fmt.parseInt(usize, cl_str, 10) catch return error.InvalidContentLength; + if (content_length > max_body_size) return error.RequestTooLarge; + if (content_length == 0) return ""; + + const header_end_pos = std.mem.indexOf(u8, raw, "\r\n\r\n") orelse return error.IncompleteBody; + const body_start = header_end_pos + 4; + const body_received = n - body_start; + if (body_received >= content_length) { + return raw[body_start .. body_start + content_length]; + } + // Need to read more data from the stream + const total_size = body_start + content_length; + const full_buf = try alloc.alloc(u8, total_size); + @memcpy(full_buf[0..n], raw); + var total_read = n; + while (total_read < total_size) { + const extra = try net_compat.streamRead(stream, full_buf[total_read..total_size]); + if (extra == 0) return error.IncompleteBody; + total_read += extra; } + return full_buf[body_start..total_size]; } return extractBody(raw); } @@ -1548,6 +2272,13 @@ fn appendCorsHeaders(writer: anytype, raw_request: []const u8, bind_host: []cons try writer.writeAll("Vary: Origin\r\n"); } +fn buildCorsHeaders(allocator: std.mem.Allocator, raw_request: []const u8, bind_host: []const u8, port: u16, extra_origins: []const []const u8) ![]u8 { + var out: std.Io.Writer.Allocating = .init(allocator); + errdefer out.deinit(); + try appendCorsHeaders(&out.writer, raw_request, bind_host, port, extra_origins); + return try out.toOwnedSlice(); +} + fn allowedCorsOrigin(raw_request: []const u8, bind_host: []const u8, port: u16, extra_origins: []const []const u8) ?[]const u8 { const origin = extractHeader(raw_request, "Origin") orelse return null; if (!isAllowedCorsOrigin(origin, bind_host, port, extra_origins)) return null; @@ -1678,6 +2409,7 @@ const TestContext = struct { } fn deinit(self: *TestContext, allocator: std.mem.Allocator) void { + self.server.mission_workflow_evidence_cache.deinit(); self.manager.deinit(); self.state.deinit(); allocator.destroy(self.state); @@ -1687,6 +2419,14 @@ const TestContext = struct { fn route(self: *TestContext, allocator: std.mem.Allocator, method: []const u8, target: []const u8, body: []const u8) Response { return self.server.route(allocator, method, target, body); } + + fn routeWithRequestArena(self: *TestContext, allocator: std.mem.Allocator, method: []const u8, target: []const u8, body: []const u8) Response { + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const resp = self.server.route(arena.allocator(), method, target, body); + const owned_body = allocator.dupe(u8, resp.body) catch @panic("OOM"); + return .{ .status = resp.status, .content_type = resp.content_type, .body = owned_body }; + } }; fn writeUiModuleEntrypoint(allocator: std.mem.Allocator, module_dir: []const u8) !void { @@ -1917,7 +2657,7 @@ test "reconcileInstancesOnBoot terminates mismatched persisted runtime without r try std.testing.expectEqualStrings("started\n", contents); } -test "reconcileInstancesOnBoot adopts legacy nullwatch launch mode as serve" { +test "reconcileInstancesOnBoot rejects mismatched nullwatch launch mode" { const builtin = @import("builtin"); if (comptime builtin.os.tag == .windows) return error.SkipZigTest; @@ -1956,12 +2696,15 @@ test "reconcileInstancesOnBoot adopts legacy nullwatch launch mode as serve" { ctx.reconcileInstancesOnBoot(); - const status = ctx.manager.getStatus("nullwatch", "watch").?; - try std.testing.expectEqual(manager_mod.Status.running, status.status); - try std.testing.expect(process_mod.isAlive(spawned.pid)); - try std.testing.expectEqualStrings("serve", ctx.state.getInstance("nullwatch", "watch").?.launch_mode); + var attempts: usize = 0; + while (attempts < 20 and process_mod.isAlive(spawned.pid)) : (attempts += 1) { + std_compat.thread.sleep(50 * std.time.ns_per_ms); + } - ctx.manager.stopInstance("nullwatch", "watch") catch {}; + try std.testing.expect(!process_mod.isAlive(spawned.pid)); + try std.testing.expect(ctx.manager.getStatus("nullwatch", "watch") == null); + try std.testing.expectEqualStrings("gateway", ctx.state.getInstance("nullwatch", "watch").?.launch_mode); + try std.testing.expect((try runtime_state_mod.load(allocator, ctx.paths, "nullwatch", "watch")) == null); _ = spawned.child.wait() catch {}; } @@ -2005,6 +2748,51 @@ test "route GET /api/spec returns route catalog alias" { try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"path_template\": \"/api/spec\"") != null); } +test "route persists mission replay artifacts through server paths" { + const allocator = std.testing.allocator; + var ctx = TestContext.init(allocator); + defer ctx.deinit(allocator); + + ctx.server.mission_control.runtime = .{ + .launched = true, + .started_at_ms = std_compat.time.milliTimestamp() - 20_000, + .recovered = true, + .recovery_started_at_ms = std_compat.time.milliTimestamp() - 12_000, + }; + + const save_resp = ctx.routeWithRequestArena(allocator, "POST", "/api/mission-control/replay/save", ""); + defer allocator.free(save_resp.body); + try std.testing.expectEqualStrings("200 OK", save_resp.status); + try std.testing.expectEqualStrings("application/json", save_resp.content_type); + + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, save_resp.body, .{ .allocate = .alloc_always }); + defer parsed.deinit(); + const record = parsed.value.object.get("record").?.object; + const id = record.get("id").?.string; + + const second_save_resp = ctx.routeWithRequestArena(allocator, "POST", "/api/mission-control/replay/save", ""); + defer allocator.free(second_save_resp.body); + try std.testing.expectEqualStrings("200 OK", second_save_resp.status); + + const bounded_resp = ctx.routeWithRequestArena(allocator, "GET", "/api/mission-control/replays?limit=0", ""); + defer allocator.free(bounded_resp.body); + try std.testing.expectEqualStrings("200 OK", bounded_resp.status); + try std.testing.expect(std.mem.indexOf(u8, bounded_resp.body, "\"count\": 1") != null); + + const list_resp = ctx.routeWithRequestArena(allocator, "GET", "/api/mission-control/replays?limit=100", ""); + defer allocator.free(list_resp.body); + try std.testing.expectEqualStrings("200 OK", list_resp.status); + try std.testing.expect(std.mem.indexOf(u8, list_resp.body, id) != null); + + const read_path = try std.fmt.allocPrint(allocator, "/api/mission-control/replays/{s}", .{id}); + defer allocator.free(read_path); + const read_resp = ctx.routeWithRequestArena(allocator, "GET", read_path, ""); + defer allocator.free(read_resp.body); + try std.testing.expectEqualStrings("200 OK", read_resp.status); + try std.testing.expect(std.mem.indexOf(u8, read_resp.body, "\"artifact_kind\": \"nullhub.mission_control.replay\"") != null); + try std.testing.expect(std.mem.indexOf(u8, read_resp.body, "\"phase\": \"completed\"") != null); +} + test "route unknown non-API path attempts static file serving" { var ctx = TestContext.init(std.testing.allocator); defer ctx.deinit(std.testing.allocator); @@ -2082,7 +2870,8 @@ test "route POST /api/components/refresh returns 200" { const resp = ctx.route(std.testing.allocator, "POST", "/api/components/refresh", ""); defer std.testing.allocator.free(resp.body); try std.testing.expectEqualStrings("200 OK", resp.status); - try std.testing.expectEqualStrings("{\"status\":\"ok\"}", resp.body); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"status\":\"ok\"") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"component_count\":4") != null); } test "extractHeader finds Content-Length" { @@ -2186,19 +2975,22 @@ test "requestOriginAllowed honors configured extra origins" { try std.testing.expect(!requestOriginAllowed(foreign_raw, "/api/status", "127.0.0.1", 19800, extras)); } -test "routeWithoutServerMutex keeps orchestration proxy requests off global lock" { - try std.testing.expect(Server.routeWithoutServerMutex("/api/orchestration")); - try std.testing.expect(Server.routeWithoutServerMutex("/api/orchestration/runs")); - try std.testing.expect(Server.routeWithoutServerMutex("/api/orchestration/store/search")); - try std.testing.expect(Server.routeWithoutServerMutex("/api/observability/v1/runs")); +test "routeWithoutServerMutex keeps product proxy requests off global lock" { + try std.testing.expect(Server.routeWithoutServerMutex("/api/nullboiler")); + try std.testing.expect(Server.routeWithoutServerMutex("/api/nullboiler/runs")); + try std.testing.expect(Server.routeWithoutServerMutex("/api/nulltickets/store/search")); + try std.testing.expect(Server.routeWithoutServerMutex("/api/nullwatch/v1/runs")); + try std.testing.expect(!Server.routeWithoutServerMutex("/api/nulltickets/tasks")); + try std.testing.expect(Server.routeWithoutServerMutex("/api/mission-control/state")); try std.testing.expect(Server.routeWithoutServerMutex("/api/instances/nullclaw/demo/logs")); try std.testing.expect(Server.routeWithoutServerMutex("/api/instances/nulltickets/tracker-a/tickets")); try std.testing.expect(!Server.routeWithoutServerMutex("/api/components")); } -test "explicit managed orchestration backend selection overrides env fallback" { +test "explicit managed product backend selection overrides env fallback" { const allocator = std.testing.allocator; var managed = Server.ManagedBackendConfig{ + .name = try allocator.dupe(u8, "worker-a"), .url = try allocator.dupe(u8, "http://127.0.0.1:8081"), .token = try allocator.dupe(u8, "managed-token"), }; @@ -2221,6 +3013,13 @@ test "explicit managed orchestration backend selection overrides env fallback" { "env-token", Server.selectBackendToken("env-token", managed, null).?, ); + const evidence = missionWorkflowEvidenceWithBackendName( + allocator, + missionWorkflowEvidenceStatus("available", "ok", 1), + managed.name, + ); + defer allocator.free(evidence.boiler_instance.?); + try std.testing.expectEqualStrings("worker-a", evidence.boiler_instance.?); } test "managed NullWatch target is discovered from supervisor state" { @@ -2564,6 +3363,14 @@ test "contentType returns correct MIME type for .html" { try std.testing.expectEqualStrings("text/html", contentType("index.html")); } +test "initial request buffer stays small while media body limit remains high" { + try std.testing.expect(initial_request_buffer_size <= 128 * 1024); + try std.testing.expect(default_max_request_size <= 128 * 1024); + try std.testing.expect(gateway_max_request_size >= 25 * 1024 * 1024); + try std.testing.expectEqual(default_max_request_size, maxRequestBodySize("/api/status")); + try std.testing.expectEqual(gateway_max_request_size, maxRequestBodySize("/api/instances/nullclaw/demo/a2a")); +} + test "contentType returns correct MIME type for .js" { try std.testing.expectEqualStrings("application/javascript", contentType("app.js")); } diff --git a/src/supervisor/manager.zig b/src/supervisor/manager.zig index e23f488..e03bd90 100644 --- a/src/supervisor/manager.zig +++ b/src/supervisor/manager.zig @@ -651,7 +651,7 @@ pub const Manager = struct { .pid = inst.pid, .port = inst.port, .uptime_seconds = uptime, - .memory_rss_bytes = null, + .memory_rss_bytes = if (inst.pid) |pid| process.getMemoryRss(pid) else null, .restart_count = inst.restart_count, .last_health_ok = inst.last_health_ok, .health_consecutive_failures = inst.health_consecutive_failures, diff --git a/src/supervisor/process.zig b/src/supervisor/process.zig index 0770673..b9bbe71 100644 --- a/src/supervisor/process.zig +++ b/src/supervisor/process.zig @@ -20,9 +20,31 @@ const darwin = if (builtin.os.tag == .macos) struct { pbsi_svgid: u32, pbsi_rfu: u32, }; + + pub const ProcTaskInfo = extern struct { + pti_virtual_size: u64, + pti_resident_size: u64, + pti_total_user: u64, + pti_total_system: u64, + pti_threads_user: u64, + pti_threads_system: u64, + pti_policy: i32, + pti_faults: i32, + pti_pageins: i32, + pti_cow_faults: i32, + pti_messages_sent: i32, + pti_messages_received: i32, + pti_syscalls_mach: i32, + pti_syscalls_unix: i32, + pti_csw: i32, + pti_threadnum: i32, + pti_numrunning: i32, + pti_priority: i32, + }; }; const PROC_PIDT_SHORTBSDINFO: i32 = 13; + const PROC_PIDTASKINFO: i32 = 4; const SZOMB: u32 = 5; extern "c" fn proc_pidinfo(pid: i32, flavor: i32, arg: u64, buffer: ?*anyopaque, buffersize: i32) c_int; @@ -326,16 +348,52 @@ pub fn forceKill(pid: std_compat.process.Child.Id) !void { /// Get the resident set size (RSS) of a process in bytes. /// -/// Returns null if the information is unavailable or not yet implemented -/// for the current platform. +/// Returns null if the information is unavailable for the current platform. pub fn getMemoryRss(pid: std_compat.process.Child.Id) ?u64 { - _ = pid; - // TODO: platform-specific RSS reading - // Linux: read /proc/{pid}/status and parse VmRSS line - // macOS: proc_pid_rusage or similar + if (comptime builtin.os.tag == .linux) return getLinuxMemoryRss(pid); + if (comptime builtin.os.tag == .macos) return getDarwinMemoryRss(pid); + return null; +} + +fn getLinuxMemoryRss(pid: std_compat.process.Child.Id) ?u64 { + var path_buf: [64]u8 = undefined; + const path = std.fmt.bufPrint(&path_buf, "/proc/{d}/status", .{pid}) catch return null; + const file = std_compat.fs.openFileAbsolute(path, .{}) catch return null; + defer file.close(); + + var status_buf: [32 * 1024]u8 = undefined; + const len = file.readAll(&status_buf) catch return null; + return parseLinuxVmRss(status_buf[0..len]); +} + +fn parseLinuxVmRss(status: []const u8) ?u64 { + var lines = std.mem.splitScalar(u8, status, '\n'); + while (lines.next()) |line| { + if (!std.mem.startsWith(u8, line, "VmRSS:")) continue; + const rest = std.mem.trim(u8, line["VmRSS:".len..], " \t"); + var parts = std.mem.tokenizeAny(u8, rest, " \t"); + const value = parts.next() orelse return null; + const unit = parts.next() orelse return null; + if (!std.mem.eql(u8, unit, "kB")) return null; + const kib = std.fmt.parseInt(u64, value, 10) catch return null; + return kib * 1024; + } return null; } +fn getDarwinMemoryRss(pid: std_compat.process.Child.Id) ?u64 { + var info: darwin.extern_structs.ProcTaskInfo = undefined; + const size = darwin.proc_pidinfo( + @intCast(pid), + darwin.PROC_PIDTASKINFO, + 0, + @ptrCast(&info), + @sizeOf(darwin.extern_structs.ProcTaskInfo), + ); + if (size != @sizeOf(darwin.extern_structs.ProcTaskInfo)) return null; + return info.pti_resident_size; +} + // ── Tests ─────────────────────────────────────────────────────────── test "spawn returns nonzero pid" { @@ -436,8 +494,21 @@ test "forceKill non-existent pid does not error" { try forceKill(99999999); } -test "getMemoryRss returns null for now" { - try std.testing.expect(getMemoryRss(1) == null); +test "parseLinuxVmRss reads kilobytes as bytes" { + try std.testing.expectEqual(@as(?u64, 12_345 * 1024), parseLinuxVmRss( + \\Name: nullhub + \\VmRSS: 12345 kB + \\Threads: 1 + )); +} + +test "getMemoryRss reads current process where supported" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + if (comptime builtin.os.tag != .linux and builtin.os.tag != .macos) return error.SkipZigTest; + + const pid: std_compat.process.Child.Id = @intCast(std.posix.getpid()); + const rss = getMemoryRss(pid) orelse return error.SkipZigTest; + try std.testing.expect(rss > 0); } test "spawn with stdout_path captures stdout and stderr" { diff --git a/tests/test_mission_control_smoke.sh b/tests/test_mission_control_smoke.sh new file mode 100755 index 0000000..265953a --- /dev/null +++ b/tests/test_mission_control_smoke.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${NULLHUB_URL:-http://127.0.0.1:19802}" + +node - "$BASE_URL" <<'NODE' +const base = process.argv[2]; + +async function api(path, method = 'GET') { + const res = await fetch(base + path, { method }); + const text = await res.text(); + const body = text ? JSON.parse(text) : null; + return { status: res.status, body }; +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +let response = await api('/api/mission-control/reset', 'POST'); +assert(response.status === 200, `reset returned ${response.status}`); +assert(response.body.schema_version === 1, 'missing schema_version'); +assert(response.body.mode === 'deterministic_local_replay', 'unexpected mission mode'); +assert(response.body.status === 'idle', `expected idle, got ${response.body.status}`); + +response = await api('/api/mission-control/recover', 'POST'); +assert(response.status === 409, `early recover returned ${response.status}`); +assert(response.body.error?.code === 'mission_not_recoverable', 'missing recover conflict code'); + +response = await api('/api/mission-control/launch', 'POST'); +assert(response.status === 200, `launch returned ${response.status}`); +assert(response.body.status === 'running', `expected running, got ${response.body.status}`); + +response = await api('/api/mission-control/launch', 'POST'); +assert(response.status === 409, `duplicate launch returned ${response.status}`); +assert(response.body.error?.code === 'mission_already_started', 'missing launch conflict code'); + +await sleep(10_500); +response = await api('/api/mission-control/state'); +assert(response.status === 200, `state returned ${response.status}`); +assert(response.body.status === 'intervention_required', `expected intervention_required, got ${response.body.status}`); +assert(response.body.controls.can_recover === true, 'expected recover control'); +assert(response.body.failure?.run_id === 'run-mission-code-red-failed', 'missing failed replay failure panel'); +assert(response.body.replay_comparison === null, 'replay comparison must not expose recovered artifact before recovery'); +const failedEvent = response.body.events.find((event) => event.title === 'Validation failed'); +assert(failedEvent?.trace?.run_id === 'run-mission-code-red-failed', 'missing failed run trace ref'); +assert(failedEvent?.trace?.eval_key === 'tool_success', 'missing failed eval trace ref'); + +response = await api('/api/mission-control/recover', 'POST'); +assert(response.status === 200, `recover returned ${response.status}`); +assert(response.body.recovered_run_id === 'run-mission-code-red-recovered', 'missing recovered run id'); + +await sleep(12_000); +response = await api('/api/mission-control/state'); +assert(response.status === 200, `final state returned ${response.status}`); +assert(response.body.status === 'completed', `expected completed, got ${response.body.status}`); +assert(response.body.telemetry.verdict === 'pass', `expected pass verdict, got ${response.body.telemetry.verdict}`); +assert(response.body.replay_comparison?.recovered?.run_id === 'run-mission-code-red-recovered', 'missing recovered replay artifact comparison'); +assert(response.body.replay_comparison?.delta?.checkpoint_reused === true, 'missing replay artifact checkpoint reuse'); +const recoveredEvent = response.body.events.find((event) => event.title === 'Recovered tests passed'); +assert(recoveredEvent?.trace?.run_id === 'run-mission-code-red-recovered', 'missing recovered run trace ref'); +const finalState = response.body; + +response = await api('/api/mission-control/replay'); +assert(response.status === 200, `replay export returned ${response.status}`); +assert(response.body.artifact_kind === 'nullhub.mission_control.replay', 'unexpected replay artifact kind'); +assert(response.body.snapshot?.status === 'completed', 'replay export missing completed snapshot'); +assert(response.body.snapshot?.replay_comparison?.recovered?.verdict === 'pass', 'replay export missing recovered artifact comparison'); +assert(response.body.replay_fixture?.scenario_id === 'mission-code-red', 'replay export missing source fixture'); +assert(response.body.ecosystem_mapping?.nullwatch?.trace_ref_source === 'events[].trace', 'replay export missing nullwatch mapping'); + +response = await api('/api/mission-control/replay/save', 'POST'); +assert(response.status === 200, `replay save returned ${response.status}`); +const savedReplayId = response.body.record?.id; +assert(savedReplayId, 'replay save missing durable record id'); +assert(response.body.record?.phase === 'completed', 'replay save missing completed phase'); + +response = await api('/api/mission-control/replays'); +assert(response.status === 200, `replay list returned ${response.status}`); +assert(response.body.items?.some((item) => item.id === savedReplayId), 'saved replay not listed'); + +response = await api(`/api/mission-control/replays/${encodeURIComponent(savedReplayId)}`); +assert(response.status === 200, `stored replay read returned ${response.status}`); +assert(response.body.artifact_kind === 'nullhub.mission_control.replay', 'stored replay missing artifact kind'); +assert(response.body.snapshot?.phase === 'completed', 'stored replay missing completed snapshot'); + +console.log(`mission-control smoke ok: ${finalState.status}, ${finalState.telemetry.spans} spans, ${finalState.telemetry.evals} evals`); +NODE diff --git a/ui/package.json b/ui/package.json index ce79cc7..0b3f7e5 100644 --- a/ui/package.json +++ b/ui/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "vite dev", "build": "vite build", + "test:mission-control": "node src/lib/missionControl/replayAutomation.test.mjs", "preview": "vite preview" }, "devDependencies": { diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index 6ed8d3d..5c0686a 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -1,6 +1,7 @@ -import { createOrchestrationApi } from '$lib/api/orchestration'; -import { createNullTicketsApi } from '$lib/api/nulltickets'; -import { encodePathSegment } from '$lib/orchestration/routes'; +import { createNullBoilerApi } from '$lib/api/nullboiler'; +import { createMissionControlApi } from '$lib/api/missionControl'; +import { createNullTicketsApi, createNullTicketsStoreApi } from '$lib/api/nulltickets'; +import { encodePathSegment } from '$lib/nullstack/path'; const BASE = '/api'; @@ -15,6 +16,31 @@ function withQuery(path: string, params: Record(path: string, options?: RequestInit): Promise { return JSON.parse(text); } +export const nullTicketsApi = createNullTicketsApi((c, n, payload) => + request(`/instances/${c}/${n}/tickets`, { + method: 'POST', + body: JSON.stringify(payload), + }), +); + +export const nullTicketsStoreApi = createNullTicketsStoreApi(request, withQuery); + +export const nullWatchApi = { + getNullWatchHealth: (params?: NullWatchTarget) => + request(withQuery('/nullwatch/health', { nullhub_watch: params?.watch })), + getNullWatchSummary: (params?: NullWatchTarget) => + request(withQuery('/nullwatch/v1/summary', { nullhub_watch: params?.watch })), + getNullWatchRuns: (params?: NullWatchTarget & { run_id?: string; source?: string; operation?: string; status?: string; model?: string; tool_name?: string; verdict?: string; dataset?: string; limit?: number }) => + request( + withQuery('/nullwatch/v1/runs', { + nullhub_watch: params?.watch, + run_id: params?.run_id, + source: params?.source, + operation: params?.operation, + status: params?.status, + model: params?.model, + tool_name: params?.tool_name, + verdict: params?.verdict, + dataset: params?.dataset, + limit: params?.limit, + }), + ), + getNullWatchRun: (runId: string, params?: NullWatchTarget) => + request( + withQuery(`/nullwatch/v1/runs/${encodeURIComponent(runId)}`, { + nullhub_watch: params?.watch, + }), + ), + getNullWatchSpans: (params?: NullWatchTarget & { run_id?: string; trace_id?: string; source?: string; operation?: string; status?: string; model?: string; tool_name?: string; task_id?: string; session_id?: string; agent_id?: string; limit?: number }) => + request( + withQuery('/nullwatch/v1/spans', { + nullhub_watch: params?.watch, + run_id: params?.run_id, + trace_id: params?.trace_id, + source: params?.source, + operation: params?.operation, + status: params?.status, + model: params?.model, + tool_name: params?.tool_name, + task_id: params?.task_id, + session_id: params?.session_id, + agent_id: params?.agent_id, + limit: params?.limit, + }), + ), + getNullWatchEvals: (params?: NullWatchTarget & { run_id?: string; verdict?: string; eval_key?: string; scorer?: string; dataset?: string; limit?: number }) => + request( + withQuery('/nullwatch/v1/evals', { + nullhub_watch: params?.watch, + run_id: params?.run_id, + verdict: params?.verdict, + eval_key: params?.eval_key, + scorer: params?.scorer, + dataset: params?.dataset, + limit: params?.limit, + }), + ), +}; + +export const missionControlApi = createMissionControlApi(request); +export const nullBoilerApi = createNullBoilerApi(request, withQuery); + export const api = { getStatus: () => request('/status'), getGlobalUsage: (window: '24h' | '7d' | '30d' | 'all' = '24h') => @@ -156,12 +251,8 @@ export const api = { method: 'POST', body: JSON.stringify(payload), }), - ...createNullTicketsApi((c, n, payload) => - request(`/instances/${c}/${n}/tickets`, { - method: 'POST', - body: JSON.stringify(payload), - }), - ), + ...nullTicketsApi, + ...nullTicketsStoreApi, putConfig: (c: string, n: string, config: any) => request(`/instances/${c}/${n}/config`, { method: 'PUT', body: JSON.stringify(config) }), getLogs: (c: string, n: string, lines = 100, source: LogSource = 'instance') => @@ -183,60 +274,9 @@ export const api = { refreshComponents: () => request('/components/refresh', { method: 'POST' }), - getObservabilityHealth: (params?: ObservabilityTarget) => - request(withQuery('/observability/health', { nullhub_watch: params?.watch })), - getObservabilitySummary: (params?: ObservabilityTarget) => - request(withQuery('/observability/v1/summary', { nullhub_watch: params?.watch })), - getObservabilityRuns: (params?: ObservabilityTarget & { run_id?: string; source?: string; operation?: string; status?: string; model?: string; tool_name?: string; verdict?: string; dataset?: string; limit?: number }) => - request( - withQuery('/observability/v1/runs', { - nullhub_watch: params?.watch, - run_id: params?.run_id, - source: params?.source, - operation: params?.operation, - status: params?.status, - model: params?.model, - tool_name: params?.tool_name, - verdict: params?.verdict, - dataset: params?.dataset, - limit: params?.limit, - }), - ), - getObservabilityRun: (runId: string, params?: ObservabilityTarget) => - request( - withQuery(`/observability/v1/runs/${encodeURIComponent(runId)}`, { - nullhub_watch: params?.watch, - }), - ), - getObservabilitySpans: (params?: ObservabilityTarget & { run_id?: string; trace_id?: string; source?: string; operation?: string; status?: string; model?: string; tool_name?: string; task_id?: string; session_id?: string; agent_id?: string; limit?: number }) => - request( - withQuery('/observability/v1/spans', { - nullhub_watch: params?.watch, - run_id: params?.run_id, - trace_id: params?.trace_id, - source: params?.source, - operation: params?.operation, - status: params?.status, - model: params?.model, - tool_name: params?.tool_name, - task_id: params?.task_id, - session_id: params?.session_id, - agent_id: params?.agent_id, - limit: params?.limit, - }), - ), - getObservabilityEvals: (params?: ObservabilityTarget & { run_id?: string; verdict?: string; eval_key?: string; scorer?: string; dataset?: string; limit?: number }) => - request( - withQuery('/observability/v1/evals', { - nullhub_watch: params?.watch, - run_id: params?.run_id, - verdict: params?.verdict, - eval_key: params?.eval_key, - scorer: params?.scorer, - dataset: params?.dataset, - limit: params?.limit, - }), - ), + ...nullWatchApi, + + ...missionControlApi, applyUpdate: (c: string, n: string) => request(`/instances/${c}/${n}/update`, { method: 'POST' }), @@ -309,5 +349,5 @@ export const api = { body: JSON.stringify(data), }), - ...createOrchestrationApi(request, withQuery), + ...nullBoilerApi, }; diff --git a/ui/src/lib/api/missionControl.ts b/ui/src/lib/api/missionControl.ts new file mode 100644 index 0000000..a768325 --- /dev/null +++ b/ui/src/lib/api/missionControl.ts @@ -0,0 +1,249 @@ +type RequestFn = (path: string, options?: RequestInit) => Promise; + +export type MissionControlStatus = 'idle' | 'running' | 'intervention_required' | 'completed'; +export type MissionControlPhase = + | 'idle' + | 'launching' + | 'research' + | 'coding' + | 'checkpoint' + | 'testing' + | 'failed' + | 'forking' + | 'patching' + | 'retesting' + | 'review' + | 'completed'; +export type MissionControlControls = { + can_launch: boolean; + can_recover: boolean; + can_reset: boolean; +}; +export type MissionControlAgent = { + id: string; + role: string; + status: string; + current_step: string; +}; +export type MissionControlGraphNode = { + id: string; + label: string; + kind: string; + status: string; +}; +export type MissionControlGraphEdge = { + from: string; + to: string; + status: string; +}; +export type MissionControlTraceRef = { + kind: 'span' | 'eval'; + run_id: string | null; + trace_id: string | null; + span_id: string | null; + eval_key: string | null; + operation: string; +}; +export type MissionControlEvent = { + at_ms: number; + source: string; + level: string; + title: string; + detail: string; + status: string; + trace: MissionControlTraceRef | null; +}; +export type MissionControlTelemetry = { + runs: number; + spans: number; + evals: number; + errors: number; + total_tokens: number; + total_cost_usd: number; + verdict: string; +}; +export type MissionControlWorkflowEvidenceStatus = + | 'available' + | 'not_configured' + | 'unavailable' + | 'not_found' + | 'ambiguous' + | 'schema_mismatch'; +export type MissionControlWorkflowEvidenceRun = { + run_id: string; + status: string; + created_at_ms: number | null; + updated_at_ms: number | null; + checkpoint_count: number | null; +}; +export type MissionControlWorkflowEvidenceCheckpoint = { + id: string; + run_id: string; + step_id: string; + parent_id: string | null; + version: number | null; + created_at_ms: number | null; + completed_nodes: string[]; + metadata: unknown; +}; +export type MissionControlWorkflowEvidence = { + status: MissionControlWorkflowEvidenceStatus | string; + source: string; + boiler_instance: string | null; + failed_run: MissionControlWorkflowEvidenceRun | null; + recovered_run: MissionControlWorkflowEvidenceRun | null; + checkpoint: MissionControlWorkflowEvidenceCheckpoint | null; + scanned_run_count: number; + reason: string | null; +}; +export type MissionControlFailure = { + run_id: string; + checkpoint_id: string; + failed_step: string; + error_message: string; + suggested_intervention: string; +}; +export type MissionControlRecovery = { + run_id: string; + forked_from: string; + human_instruction: string; + status: string; +}; +export type MissionControlReplayArtifactPanel = { + artifact_kind: string; + artifact_role: 'failed' | 'recovered' | string; + run_id: string; + workflow_run_id: string | null; + workflow_status: string | null; + phase: string; + status: string; + headline: string; + verdict: string; + trace_id: string | null; + checkpoint_id: string | null; + checkpoint_step: string | null; + forked_from: string | null; + human_instruction: string | null; + failure_message: string | null; + telemetry: MissionControlTelemetry; +}; +export type MissionControlReplayArtifactDelta = { + verdict_changed: boolean; + checkpoint_reused: boolean; + spans_delta: number; + evals_delta: number; + errors_delta: number; + tokens_delta: number; + cost_delta_usd: number; +}; +export type MissionControlReplayComparison = { + failed: MissionControlReplayArtifactPanel; + recovered: MissionControlReplayArtifactPanel; + delta: MissionControlReplayArtifactDelta; +}; +export type MissionControlState = { + schema_version: number; + mode: string; + scenario_id: string; + scenario_version: string; + generated_at_ms: number; + mission_id: string; + title: string; + status: MissionControlStatus; + phase: MissionControlPhase; + headline: string; + elapsed_ms: number; + progress: number; + active_run_id: string | null; + failed_run_id: string | null; + recovered_run_id: string | null; + controls: MissionControlControls; + agents: MissionControlAgent[]; + graph: { + nodes: MissionControlGraphNode[]; + edges: MissionControlGraphEdge[]; + }; + events: MissionControlEvent[]; + telemetry: MissionControlTelemetry; + workflow_evidence: MissionControlWorkflowEvidence; + replay_comparison: MissionControlReplayComparison | null; + failure: MissionControlFailure | null; + recovery: MissionControlRecovery | null; +}; +export type MissionControlComponentMapping = { + component: string; + role: string; + evidence: string[]; +}; +export type MissionControlWorkflowMapping = MissionControlComponentMapping & { + status: string; + source: string; + boiler_instance: string | null; + checkpoint_id: string; + failed_run_id: string; + recovered_run_id: string; + human_instruction: string; +}; +export type MissionControlNullWatchMapping = MissionControlComponentMapping & { + failed_run_id: string; + recovered_run_id: string; + trace_ref_source: string; +}; +export type MissionControlReplayArtifact = { + artifact_schema_version: number; + artifact_kind: string; + generated_at_ms: number; + replay_fixture_path: string; + scenario_id: string; + scenario_version: string; + mode: string; + snapshot: MissionControlState; + replay_fixture: unknown; + workflow_evidence: MissionControlWorkflowEvidence; + ecosystem_mapping: { + nulltickets: MissionControlComponentMapping; + nullboiler: MissionControlWorkflowMapping; + nullclaw: MissionControlComponentMapping; + nullwatch: MissionControlNullWatchMapping; + }; +}; +export type MissionControlReplayRecord = { + id: string; + saved_at_ms: number; + generated_at_ms: number; + scenario_id: string; + scenario_version: string; + mission_id: string; + title: string; + status: string; + phase: string; + artifact_kind: string; + artifact_path: string; + size_bytes: number; +}; +export type MissionControlReplayList = { + items: MissionControlReplayRecord[]; + count: number; +}; +export type MissionControlReplaySaveResult = { + record: MissionControlReplayRecord; + artifact: MissionControlReplayArtifact; +}; + +export function createMissionControlApi(request: RequestFn) { + return { + getMissionControlState: () => request('/mission-control/state'), + getMissionControlReplay: () => request('/mission-control/replay'), + saveMissionControlReplay: () => + request('/mission-control/replay/save', { method: 'POST' }), + listMissionControlReplays: () => request('/mission-control/replays'), + getStoredMissionControlReplay: (id: string) => + request(`/mission-control/replays/${encodeURIComponent(id)}`), + launchMissionControl: () => + request('/mission-control/launch', { method: 'POST' }), + resetMissionControl: () => + request('/mission-control/reset', { method: 'POST' }), + recoverMissionControl: () => + request('/mission-control/recover', { method: 'POST' }), + }; +} diff --git a/ui/src/lib/api/orchestration.ts b/ui/src/lib/api/nullboiler.ts similarity index 63% rename from ui/src/lib/api/orchestration.ts rename to ui/src/lib/api/nullboiler.ts index 7f39c96..53e6bac 100644 --- a/ui/src/lib/api/orchestration.ts +++ b/ui/src/lib/api/nullboiler.ts @@ -1,5 +1,5 @@ -import { orchestrationApiPaths } from '$lib/orchestration/routes'; -import { getSelectedBoilerInstance } from '$lib/orchestration/backendSelection'; +import { nullboilerApiPaths } from '$lib/nullboiler/routes'; +import { getSelectedBoilerInstance } from '$lib/nullstack/backendSelection'; type RequestFn = (path: string, options?: RequestInit) => Promise; type WithQueryFn = ( @@ -29,8 +29,6 @@ export type RunStreamHandle = { readonly closed: boolean; }; -const orchestrationStorePrefix = '/orchestration/store'; - function msToIso(ms: number | undefined | null): string | undefined { if (ms == null) return undefined; return new Date(ms).toISOString(); @@ -136,82 +134,72 @@ function normalizeStreamEvent(raw: any): { type: string; data: any; timestamp?: }; } -export function createOrchestrationApi(request: RequestFn, withQuery: WithQueryFn) { +export function createNullBoilerApi(request: RequestFn, withQuery: WithQueryFn) { function withBoilerQuery(path: string, params: QueryParams = {}, boilerInstance?: string) { - const selectedBoiler = path.startsWith(orchestrationStorePrefix) - ? '' - : boilerInstance ?? getSelectedBoilerInstance(); + const selectedBoiler = boilerInstance ?? getSelectedBoilerInstance(); return withQuery(path, { ...params, boiler_instance: selectedBoiler || undefined }); } async function listRunsPage(params?: RunListParams): Promise { const { boilerInstance, ...query } = params ?? {}; - const raw = await request(withBoilerQuery(orchestrationApiPaths.runs(), query, boilerInstance)); + const raw = await request(withBoilerQuery(nullboilerApiPaths.runs(), query, boilerInstance)); return normalizeRunListPage(raw); } return { listWorkflows: async (options?: BoilerOptions) => { - const raw = await request(withBoilerQuery(orchestrationApiPaths.workflows(), {}, options?.boilerInstance)); + const raw = await request(withBoilerQuery(nullboilerApiPaths.workflows(), {}, options?.boilerInstance)); const list = Array.isArray(raw) ? raw : raw?.items ?? []; return list.map(normalizeWorkflow); }, getWorkflow: async (id: string, options?: BoilerOptions) => - normalizeWorkflow(await request(withBoilerQuery(orchestrationApiPaths.workflow(id), {}, options?.boilerInstance))), + normalizeWorkflow(await request(withBoilerQuery(nullboilerApiPaths.workflow(id), {}, options?.boilerInstance))), createWorkflow: (data: any, options?: BoilerOptions) => - request(withBoilerQuery(orchestrationApiPaths.workflows(), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify(data) }), + request(withBoilerQuery(nullboilerApiPaths.workflows(), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify(data) }), updateWorkflow: (id: string, data: any, options?: BoilerOptions) => - request(withBoilerQuery(orchestrationApiPaths.workflow(id), {}, options?.boilerInstance), { method: 'PUT', body: JSON.stringify(data) }), + request(withBoilerQuery(nullboilerApiPaths.workflow(id), {}, options?.boilerInstance), { method: 'PUT', body: JSON.stringify(data) }), deleteWorkflow: (id: string, options?: BoilerOptions) => - request(withBoilerQuery(orchestrationApiPaths.workflow(id), {}, options?.boilerInstance), { method: 'DELETE' }), + request(withBoilerQuery(nullboilerApiPaths.workflow(id), {}, options?.boilerInstance), { method: 'DELETE' }), validateWorkflow: async (id: string, options?: BoilerOptions) => - normalizeValidation(await request(withBoilerQuery(orchestrationApiPaths.workflowValidate(id), {}, options?.boilerInstance), { method: 'POST' })), + normalizeValidation(await request(withBoilerQuery(nullboilerApiPaths.workflowValidate(id), {}, options?.boilerInstance), { method: 'POST' })), runWorkflow: (id: string, input: any, options?: BoilerOptions) => - request(withBoilerQuery(orchestrationApiPaths.workflowRun(id), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify(input) }), + request(withBoilerQuery(nullboilerApiPaths.workflowRun(id), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify(input) }), listRunsPage, listRuns: async (params?: RunListParams) => (await listRunsPage(params)).items, getRun: async (id: string, options?: BoilerOptions) => - normalizeRun(await request(withBoilerQuery(orchestrationApiPaths.run(id), {}, options?.boilerInstance))), + normalizeRun(await request(withBoilerQuery(nullboilerApiPaths.run(id), {}, options?.boilerInstance))), cancelRun: (id: string, options?: BoilerOptions) => - request(withBoilerQuery(orchestrationApiPaths.runCancel(id), {}, options?.boilerInstance), { method: 'POST' }), + request(withBoilerQuery(nullboilerApiPaths.runCancel(id), {}, options?.boilerInstance), { method: 'POST' }), retryRun: (id: string, options?: BoilerOptions) => - request(withBoilerQuery(orchestrationApiPaths.runRetry(id), {}, options?.boilerInstance), { method: 'POST' }), + request(withBoilerQuery(nullboilerApiPaths.runRetry(id), {}, options?.boilerInstance), { method: 'POST' }), resumeRun: (id: string, updates: any, options?: BoilerOptions) => - request(withBoilerQuery(orchestrationApiPaths.runResume(id), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify({ state_updates: updates }) }), + request(withBoilerQuery(nullboilerApiPaths.runResume(id), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify({ state_updates: updates }) }), forkRun: (checkpointId: string, overrides?: any, options?: BoilerOptions) => - request(withBoilerQuery(orchestrationApiPaths.runsFork(), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify({ checkpoint_id: checkpointId, state_overrides: overrides }) }), + request(withBoilerQuery(nullboilerApiPaths.runsFork(), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify({ checkpoint_id: checkpointId, state_overrides: overrides }) }), replayRun: (id: string, checkpointId: string, options?: BoilerOptions) => - request(withBoilerQuery(orchestrationApiPaths.runReplay(id), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify({ from_checkpoint_id: checkpointId }) }), + request(withBoilerQuery(nullboilerApiPaths.runReplay(id), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify({ from_checkpoint_id: checkpointId }) }), injectState: (id: string, updates: any, afterStep?: string, options?: BoilerOptions) => - request(withBoilerQuery(orchestrationApiPaths.runState(id), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify({ updates, apply_after_step: afterStep }) }), + request(withBoilerQuery(nullboilerApiPaths.runState(id), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify({ updates, apply_after_step: afterStep }) }), listCheckpoints: async (runId: string, options?: BoilerOptions) => { - const cps = await request(withBoilerQuery(orchestrationApiPaths.runCheckpoints(runId), {}, options?.boilerInstance)); + const cps = await request(withBoilerQuery(nullboilerApiPaths.runCheckpoints(runId), {}, options?.boilerInstance)); return (cps || []).map(normalizeCheckpoint); }, getCheckpoint: async (runId: string, cpId: string, options?: BoilerOptions) => - normalizeCheckpoint(await request(withBoilerQuery(orchestrationApiPaths.runCheckpoint(runId, cpId), {}, options?.boilerInstance))), + normalizeCheckpoint(await request(withBoilerQuery(nullboilerApiPaths.runCheckpoint(runId, cpId), {}, options?.boilerInstance))), getBoilerTrackerStatus: (options?: BoilerOptions) => - request(withBoilerQuery(orchestrationApiPaths.trackerStatus(), {}, options?.boilerInstance)), + request(withBoilerQuery(nullboilerApiPaths.trackerStatus(), {}, options?.boilerInstance)), getBoilerTrackerTasks: (options?: BoilerOptions) => - request(withBoilerQuery(orchestrationApiPaths.trackerTasks(), {}, options?.boilerInstance)), + request(withBoilerQuery(nullboilerApiPaths.trackerTasks(), {}, options?.boilerInstance)), getBoilerTrackerStats: (options?: BoilerOptions) => - request(withBoilerQuery(orchestrationApiPaths.trackerStats(), {}, options?.boilerInstance)), + request(withBoilerQuery(nullboilerApiPaths.trackerStats(), {}, options?.boilerInstance)), refreshBoilerTracker: (options?: BoilerOptions) => - request(withBoilerQuery(orchestrationApiPaths.trackerRefresh(), {}, options?.boilerInstance), { method: 'POST' }), + request(withBoilerQuery(nullboilerApiPaths.trackerRefresh(), {}, options?.boilerInstance), { method: 'POST' }), listWorkers: (options?: BoilerOptions) => - request(withBoilerQuery(orchestrationApiPaths.workers(), {}, options?.boilerInstance)), + request(withBoilerQuery(nullboilerApiPaths.workers(), {}, options?.boilerInstance)), registerWorker: (data: any, options?: BoilerOptions) => - request(withBoilerQuery(orchestrationApiPaths.workers(), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify(data) }), + request(withBoilerQuery(nullboilerApiPaths.workers(), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify(data) }), deleteWorker: (id: string, options?: BoilerOptions) => - request(withBoilerQuery(orchestrationApiPaths.worker(id), {}, options?.boilerInstance), { method: 'DELETE' }), - storeList: (namespace: string, ticketsInstance?: string) => - request(withQuery(orchestrationApiPaths.storeNamespace(namespace), { tickets_instance: ticketsInstance })), - storeGet: (namespace: string, key: string, ticketsInstance?: string) => - request(withQuery(orchestrationApiPaths.storeEntry(namespace, key), { tickets_instance: ticketsInstance })), - storePut: (namespace: string, key: string, value: any, ticketsInstance?: string) => - request(withQuery(orchestrationApiPaths.storeEntry(namespace, key), { tickets_instance: ticketsInstance }), { method: 'PUT', body: JSON.stringify({ value }) }), - storeDelete: (namespace: string, key: string, ticketsInstance?: string) => - request(withQuery(orchestrationApiPaths.storeEntry(namespace, key), { tickets_instance: ticketsInstance }), { method: 'DELETE' }), + request(withBoilerQuery(nullboilerApiPaths.worker(id), {}, options?.boilerInstance), { method: 'DELETE' }), streamRun: ( runId: string, onEvent: (event: { type: string; data: any; timestamp?: number }) => void, @@ -230,7 +218,7 @@ export function createOrchestrationApi(request: RequestFn, withQuery: WithQueryF const poll = async () => { while (active) { try { - const res = await request(withBoilerQuery(orchestrationApiPaths.runStream(runId), { + const res = await request(withBoilerQuery(nullboilerApiPaths.runStream(runId), { after_seq: afterSeq > 0 ? afterSeq : undefined, }, options?.boilerInstance)); if (!active) break; diff --git a/ui/src/lib/api/nulltickets.ts b/ui/src/lib/api/nulltickets.ts index deeeac6..bf0af9d 100644 --- a/ui/src/lib/api/nulltickets.ts +++ b/ui/src/lib/api/nulltickets.ts @@ -1,4 +1,6 @@ -import { encodePathSegment } from '$lib/orchestration/routes'; +import { nullticketsApiPaths } from '$lib/nulltickets/routes'; +import { encodePathSegment } from '$lib/nullstack/path'; +import { getSelectedTicketsInstance } from '$lib/nullstack/backendSelection'; export type NullTicketsHttpMethod = 'GET' | 'POST' | 'DELETE'; @@ -10,6 +12,11 @@ export type NullTicketsActionRequest = { }; type NullTicketsActionFn = (component: string, name: string, payload: NullTicketsActionRequest) => Promise; +type RequestFn = (path: string, options?: RequestInit) => Promise; +type WithQueryFn = ( + path: string, + params: Record, +) => string; type QueryValue = string | number | boolean | null | undefined; function withNullTicketsQuery(path: string, params: Record): string { @@ -160,3 +167,26 @@ export function createNullTicketsApi(action: NullTicketsActionFn) { action(c, n, { method: 'POST', path: ticketsPaths.artifactCollection(), payload }), }; } + +export function createNullTicketsStoreApi(request: RequestFn, withQuery: WithQueryFn) { + function selectedTicketsQuery(ticketsInstance?: string) { + const selected = ticketsInstance ?? getSelectedTicketsInstance(); + return { tickets_instance: selected || undefined }; + } + + return { + storeList: (namespace: string, ticketsInstance?: string) => + request(withQuery(nullticketsApiPaths.storeNamespace(namespace), selectedTicketsQuery(ticketsInstance))), + storeGet: (namespace: string, key: string, ticketsInstance?: string) => + request(withQuery(nullticketsApiPaths.storeEntry(namespace, key), selectedTicketsQuery(ticketsInstance))), + storePut: (namespace: string, key: string, value: any, ticketsInstance?: string) => + request(withQuery(nullticketsApiPaths.storeEntry(namespace, key), selectedTicketsQuery(ticketsInstance)), { + method: 'PUT', + body: JSON.stringify({ value }), + }), + storeDelete: (namespace: string, key: string, ticketsInstance?: string) => + request(withQuery(nullticketsApiPaths.storeEntry(namespace, key), selectedTicketsQuery(ticketsInstance)), { + method: 'DELETE', + }), + }; +} diff --git a/ui/src/lib/components/orchestration/ManagedInstanceSelector.svelte b/ui/src/lib/components/ManagedInstanceSelector.svelte similarity index 100% rename from ui/src/lib/components/orchestration/ManagedInstanceSelector.svelte rename to ui/src/lib/components/ManagedInstanceSelector.svelte diff --git a/ui/src/lib/components/NullBoilerPanel.svelte b/ui/src/lib/components/NullBoilerPanel.svelte index e5f9638..1ccc6c3 100644 --- a/ui/src/lib/components/NullBoilerPanel.svelte +++ b/ui/src/lib/components/NullBoilerPanel.svelte @@ -1,12 +1,12 @@ + +
+
+

Replay Artifacts

+ {`${replayComparison.failed.verdict} -> ${replayComparison.recovered.verdict}`} +
+
+
Verdict{replayComparison.failed.verdict} -> {replayComparison.recovered.verdict}
+
New errors 0}>{signedMetric(replayComparison.delta.errors_delta)}
+
Extra tokens{signedMetric(replayComparison.delta.tokens_delta)}
+
Extra cost{signedMetric(replayComparison.delta.cost_delta_usd, 3)}
+
+
+
+ Failed artifact + {replayComparison.failed.run_id} +
+
Phase
{replayComparison.failed.phase}
+
Verdict
{replayComparison.failed.verdict}
+
Errors
{replayComparison.failed.telemetry.errors}
+
Tokens
{formatTokens(replayComparison.failed.telemetry.total_tokens)}
+
+

{replayComparison.failed.failure_message || replayComparison.failed.headline}

+ {replayComparison.failed.trace_id || '-'} +

Workflow {artifactWorkflowLabel(replayComparison.failed)}

+

Checkpoint {artifactAnchorLabel(replayComparison.failed)}

+ Open failed trace + {#if replayComparison.failed.workflow_run_id} + Open failed workflow + {/if} +
+ +
+ Recovered artifact + {replayComparison.recovered.run_id} +
+
Phase
{replayComparison.recovered.phase}
+
Verdict
{replayComparison.recovered.verdict}
+
Errors
{replayComparison.recovered.telemetry.errors}
+
Tokens
{formatTokens(replayComparison.recovered.telemetry.total_tokens)}
+
+

{replayComparison.recovered.human_instruction || replayComparison.recovered.headline}

+ {replayComparison.recovered.trace_id || '-'} +

Workflow {artifactWorkflowLabel(replayComparison.recovered)}

+

Forked from {artifactAnchorLabel(replayComparison.recovered)}

+ Open recovered trace + {#if replayComparison.recovered.workflow_run_id} + Open recovered workflow + {/if} +
+
+
+ + diff --git a/ui/src/lib/missionControl/display.ts b/ui/src/lib/missionControl/display.ts new file mode 100644 index 0000000..edc32bf --- /dev/null +++ b/ui/src/lib/missionControl/display.ts @@ -0,0 +1,231 @@ +import type { + MissionControlControls, + MissionControlPhase, + MissionControlTelemetry, +} from '$lib/api/missionControl'; +import { isAvailableTrace, type TraceHydration } from '$lib/missionControl/traceHydration'; + +export const emptyControls: MissionControlControls = { + can_launch: false, + can_recover: false, + can_reset: true, +}; + +export const emptyTelemetry: MissionControlTelemetry = { + runs: 0, + spans: 0, + evals: 0, + errors: 0, + total_tokens: 0, + total_cost_usd: 0, + verdict: '-', +}; + +export const phaseOrder: MissionControlPhase[] = [ + 'idle', + 'launching', + 'research', + 'coding', + 'checkpoint', + 'testing', + 'failed', + 'forking', + 'patching', + 'retesting', + 'review', + 'completed', +]; + +export const phaseMilestones: { + phase: MissionControlPhase; + time: string; + title: string; + detail: string; + tone?: 'error' | 'success'; +}[] = [ + { + phase: 'launching', + time: '0:00', + title: 'Launch', + detail: 'Backlog claim and workflow dispatch start.', + }, + { + phase: 'checkpoint', + time: '0:45', + title: 'Checkpoint', + detail: 'Agents save state before validation.', + }, + { + phase: 'failed', + time: '1:10', + title: 'Failure', + detail: 'Test telemetry flags a failed tool call.', + tone: 'error', + }, + { + phase: 'forking', + time: '1:35', + title: 'Intervene', + detail: 'Human forks from checkpoint with a fix instruction.', + }, + { + phase: 'retesting', + time: '2:05', + title: 'Replay', + detail: 'Recovered run replays validation.', + }, + { + phase: 'completed', + time: '2:30', + title: 'Review', + detail: 'Recovered mission reaches a passing verdict.', + tone: 'success', + }, +]; + +export function statusClass(value: string | undefined): string { + if (value === 'done' || value === 'completed' || value === 'pass') return 'done'; + if (value === 'active' || value === 'running' || value === 'recovering') return 'active'; + if (value === 'error' || value === 'failed' || value === 'fail' || value === 'intervention_required') return 'error'; + if (value === 'blocked') return 'warning'; + return 'pending'; +} + +export function formatDuration(ms: number | undefined | null): string { + if (ms == null || ms <= 0) return '0.0s'; + return `${(ms / 1000).toFixed(1)}s`; +} + +export function formatCost(cost: number | undefined | null): string { + if (!cost) return '$0.000'; + return `$${cost.toFixed(3)}`; +} + +export function formatTokens(tokens: number | undefined | null): string { + return tokens ? tokens.toLocaleString() : '0'; +} + +export function formatScore(score: number | undefined | null): string { + return score == null ? '-' : score.toFixed(2); +} + +export function formatBytes(value: number | undefined | null): string { + if (!value) return '0 B'; + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / (1024 * 1024)).toFixed(1)} MB`; +} + +export function signedMetric(value: number, decimals = 0): string { + const formatted = decimals > 0 ? value.toFixed(decimals) : Math.trunc(value).toLocaleString(); + return value > 0 ? `+${formatted}` : formatted; +} + +export function traceSourceLabel(trace: TraceHydration | null, hydrating: boolean): string { + if (isAvailableTrace(trace)) return 'Live NullWatch'; + if (hydrating) return 'Checking NullWatch'; + if (trace?.message) return trace.message; + return 'NullWatch unavailable'; +} + +export function traceSourceSummary(options: { + liveTraceAvailable: boolean; + traceHydrating: boolean; + hasRunIds: boolean; +}): string { + if (options.liveTraceAvailable) return 'Live NullWatch'; + if (options.traceHydrating && options.hasRunIds) return 'Checking NullWatch'; + return 'NullWatch unavailable'; +} + +export function tracePanelNote(options: { + liveTraceAvailable: boolean; + traceHydrating: boolean; + hasRunIds: boolean; + hasWatch: boolean; + unavailableMessage?: string | null; +}): string { + if (options.liveTraceAvailable) return 'Hydrated run detail'; + if (options.traceHydrating && options.hasRunIds) return 'Looking for live traces'; + if (options.unavailableMessage) return options.unavailableMessage; + if (options.hasRunIds && options.hasWatch) return 'No matching live runs'; + if (options.hasRunIds) return 'No running instance'; + return 'No run ids'; +} + +export function spanCount(trace: TraceHydration | null): number { + if (!isAvailableTrace(trace)) return 0; + return trace.summary?.span_count ?? trace.spans.length ?? 0; +} + +export function evalCount(trace: TraceHydration | null): number { + if (!isAvailableTrace(trace)) return 0; + return trace.summary?.eval_count ?? trace.evals.length ?? 0; +} + +export function errorCount(trace: TraceHydration | null): number { + if (!isAvailableTrace(trace)) return 0; + return trace.summary?.error_count ?? trace.spans.filter((span) => span.status === 'error' || span.error_message).length; +} + +export function tokenCount(trace: TraceHydration | null): number { + if (!isAvailableTrace(trace) || !trace.summary) return 0; + return (trace.summary.total_input_tokens || 0) + (trace.summary.total_output_tokens || 0); +} + +export function traceCost(trace: TraceHydration | null): number { + if (!isAvailableTrace(trace)) return 0; + return trace.summary?.total_cost_usd || 0; +} + +export function traceVerdict(trace: TraceHydration | null): string | null { + if (!isAvailableTrace(trace)) return null; + if (trace.summary?.overall_verdict) return trace.summary.overall_verdict; + const failedEval = trace.evals.find((evaluation) => evaluation.verdict === 'fail'); + if (failedEval) return 'fail'; + const passedEval = trace.evals.find((evaluation) => evaluation.verdict === 'pass'); + if (passedEval) return 'pass'; + return 'live'; +} + +export function traceSuffix(trace: TraceHydration | null): string { + return isAvailableTrace(trace) ? ` · ${spanCount(trace)} spans · ${evalCount(trace)} evals` : ''; +} + +export function primaryErrorText(trace: TraceHydration | null): string { + if (!isAvailableTrace(trace)) return ''; + const span = trace.spans.find((item) => item.status === 'error' || item.error_message); + if (!span) return ''; + const operation = span.operation || span.tool_name || 'span'; + const detail = span.error_message || span.status || 'error'; + return `${operation}: ${detail}`; +} + +export function primaryEvalText(trace: TraceHydration | null, evalKey?: string): string { + if (!isAvailableTrace(trace)) return ''; + const evaluation = + (evalKey ? trace.evals.find((item) => item.eval_key === evalKey) : null) || + trace.evals.find((item) => item.verdict === 'fail') || + trace.evals[0]; + if (!evaluation) return ''; + const key = evaluation.eval_key || 'eval'; + const verdict = evaluation.verdict || 'unknown'; + return `${key}: ${verdict} (${formatScore(evaluation.score)})`; +} + +export function hydratedTelemetry(base: MissionControlTelemetry, traces: (TraceHydration | null)[]): MissionControlTelemetry { + const liveTraces = traces.filter(isAvailableTrace); + if (liveTraces.length === 0) return base; + + const verdicts = liveTraces.map(traceVerdict).filter((value): value is string => Boolean(value)); + return { + ...base, + runs: liveTraces.length, + spans: liveTraces.reduce((total, trace) => total + spanCount(trace), 0), + evals: liveTraces.reduce((total, trace) => total + evalCount(trace), 0), + errors: liveTraces.reduce((total, trace) => total + errorCount(trace), 0), + total_tokens: liveTraces.reduce((total, trace) => total + tokenCount(trace), 0), + total_cost_usd: liveTraces.reduce((total, trace) => total + traceCost(trace), 0), + verdict: verdicts.includes('fail') ? 'fail' : verdicts.includes('pass') ? 'pass' : base.verdict, + }; +} diff --git a/ui/src/lib/missionControl/replayAutomation.js b/ui/src/lib/missionControl/replayAutomation.js new file mode 100644 index 0000000..eb02b67 --- /dev/null +++ b/ui/src/lib/missionControl/replayAutomation.js @@ -0,0 +1,68 @@ +export const REPLAY_AUTOMATION_PREROLL_MS = 1200; +export const REPLAY_AUTOMATION_FAILURE_HOLD_MS = 1800; +export const REPLAY_AUTOMATION_TIMEOUT_MS = 45000; + +export function nextReplayAutomationTransition(snapshot, progress, nowMs) { + if (!progress.active) { + return { ...progress, action: null, error: null }; + } + + if (nowMs - progress.startedAtMs > REPLAY_AUTOMATION_TIMEOUT_MS) { + return { + active: false, + stage: 'idle', + startedAtMs: progress.startedAtMs, + recoverAfterMs: 0, + action: null, + error: 'Replay automation timed out before completion.', + }; + } + + if (snapshot.status === 'completed') { + return { + active: false, + stage: 'idle', + startedAtMs: progress.startedAtMs, + recoverAfterMs: 0, + action: null, + error: null, + }; + } + + if (snapshot.recovery) { + return { + ...progress, + stage: 'watching', + action: null, + error: null, + }; + } + + if (!snapshot.controls?.can_recover) { + return { + ...progress, + stage: 'waiting_failure', + action: null, + error: null, + }; + } + + const recoverAfterMs = progress.recoverAfterMs || nowMs + REPLAY_AUTOMATION_FAILURE_HOLD_MS; + if (nowMs < recoverAfterMs) { + return { + ...progress, + stage: 'holding_failure', + recoverAfterMs, + action: null, + error: null, + }; + } + + return { + ...progress, + stage: 'recovering', + recoverAfterMs, + action: 'recover', + error: null, + }; +} diff --git a/ui/src/lib/missionControl/replayAutomation.test.mjs b/ui/src/lib/missionControl/replayAutomation.test.mjs new file mode 100644 index 0000000..c853f03 --- /dev/null +++ b/ui/src/lib/missionControl/replayAutomation.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { + REPLAY_AUTOMATION_FAILURE_HOLD_MS, + REPLAY_AUTOMATION_TIMEOUT_MS, + nextReplayAutomationTransition, +} from './replayAutomation.js'; + +const baseProgress = { + active: true, + stage: 'waiting_failure', + startedAtMs: 1000, + recoverAfterMs: 0, +}; + +const waiting = nextReplayAutomationTransition( + { status: 'running', controls: { can_recover: false } }, + baseProgress, + 2000, +); +assert.equal(waiting.stage, 'waiting_failure'); +assert.equal(waiting.action, null); + +const holding = nextReplayAutomationTransition( + { status: 'intervention_required', controls: { can_recover: true } }, + baseProgress, + 3000, +); +assert.equal(holding.stage, 'holding_failure'); +assert.equal(holding.recoverAfterMs, 3000 + REPLAY_AUTOMATION_FAILURE_HOLD_MS); +assert.equal(holding.action, null); + +const recovering = nextReplayAutomationTransition( + { status: 'intervention_required', controls: { can_recover: true } }, + { ...holding, recoverAfterMs: 3000 }, + 3000, +); +assert.equal(recovering.stage, 'recovering'); +assert.equal(recovering.action, 'recover'); + +const completed = nextReplayAutomationTransition( + { status: 'completed', controls: { can_recover: false } }, + baseProgress, + 4000, +); +assert.equal(completed.active, false); +assert.equal(completed.stage, 'idle'); + +const timedOut = nextReplayAutomationTransition( + { status: 'running', controls: { can_recover: false } }, + baseProgress, + 1000 + REPLAY_AUTOMATION_TIMEOUT_MS + 1, +); +assert.equal(timedOut.active, false); +assert.equal(timedOut.stage, 'idle'); +assert.match(timedOut.error, /timed out/); diff --git a/ui/src/lib/missionControl/traceHydration.ts b/ui/src/lib/missionControl/traceHydration.ts new file mode 100644 index 0000000..d4b09ed --- /dev/null +++ b/ui/src/lib/missionControl/traceHydration.ts @@ -0,0 +1,228 @@ +import type { MissionControlState } from '$lib/api/missionControl'; + +type NullWatchTarget = { + watch?: string; +}; + +type TraceHydrationApi = { + getStatus: () => Promise; + getNullWatchRuns: ( + params?: NullWatchTarget & { run_id?: string; limit?: number }, + ) => Promise; + getNullWatchRun: ( + runId: string, + params?: NullWatchTarget, + ) => Promise; +}; + +export type NullWatchRunSummary = { + run_id?: string; + span_count?: number; + eval_count?: number; + error_count?: number; + total_input_tokens?: number; + total_output_tokens?: number; + total_cost_usd?: number; + total_duration_ms?: number; + overall_verdict?: string; +}; + +export type NullWatchSpan = { + operation?: string; + status?: string; + source?: string; + duration_ms?: number; + error_message?: string; + tool_name?: string; +}; + +export type NullWatchEval = { + eval_key?: string; + verdict?: string; + score?: number; + scorer?: string; + dataset?: string; + notes?: string; +}; + +export type TraceHydrationStatus = 'available' | 'unavailable'; +export type TraceHydrationUnavailableReason = + | 'no_running_nullwatch' + | 'status_unavailable' + | 'run_not_found' + | 'nullwatch_unavailable' + | 'run_detail_unavailable' + | 'empty_run_detail'; + +export type TraceHydration = { + runId: string; + status: TraceHydrationStatus; + unavailableReason?: TraceHydrationUnavailableReason; + message?: string; + summary: NullWatchRunSummary | null; + spans: NullWatchSpan[]; + evals: NullWatchEval[]; + loadedAtMs: number; +}; + +type NullWatchOption = { + name: string; + status: string; +}; + +export type NullWatchSelection = { + watch: string | null; + unavailableReason?: TraceHydrationUnavailableReason; + message?: string; +}; + +type StatusPayload = { + instances?: { + nullwatch?: Record; + }; +}; + +type NullWatchRunsPayload = { + items?: Array<{ run_id?: string }>; +}; + +type NullWatchRunDetail = { + summary?: NullWatchRunSummary; + run?: NullWatchRunSummary; + spans?: NullWatchSpan[]; + evals?: NullWatchEval[]; +}; + +export async function findRunningNullWatch(api: TraceHydrationApi): Promise { + try { + const status = await api.getStatus(); + const watch = runningTraceWatchName(status); + if (watch) return { watch }; + return { + watch: null, + unavailableReason: 'no_running_nullwatch', + message: unavailableTraceMessage('no_running_nullwatch'), + }; + } catch { + return { + watch: null, + unavailableReason: 'status_unavailable', + message: unavailableTraceMessage('status_unavailable'), + }; + } +} + +export async function hydrateMissionTracePanels( + api: TraceHydrationApi, + snapshot: MissionControlState, + watch: string | null, + unavailableReason: TraceHydrationUnavailableReason = 'no_running_nullwatch', +): Promise> { + const runIds = missionTracePanelRunIds(snapshot); + if (runIds.length === 0) return {}; + if (!watch) { + return Object.fromEntries( + runIds.map((runId) => [ + runId, + unavailableTrace(runId, unavailableReason), + ]), + ); + } + + const entries = await Promise.all(runIds.map((runId) => loadTraceHydration(api, runId, watch))); + return Object.fromEntries(entries.map((entry) => [entry.runId, entry])); +} + +export function isAvailableTrace(trace: TraceHydration | null | undefined): trace is TraceHydration { + return trace?.status === 'available'; +} + +function runningTraceWatchName(status: StatusPayload): string | null { + const watches = extractNullWatchOptions(status); + const running = watches.find((watch) => watch.status === 'running'); + return running?.name || null; +} + +function extractNullWatchOptions(status: StatusPayload): NullWatchOption[] { + const instances = status?.instances?.nullwatch || {}; + return Object.entries(instances).map(([name, info]) => ({ + name, + status: info?.status || 'stopped', + })); +} + +async function loadTraceHydration( + api: TraceHydrationApi, + runId: string, + watch: string, +): Promise { + let listed: NullWatchRunsPayload; + try { + listed = await api.getNullWatchRuns({ run_id: runId, limit: 1, watch }); + } catch { + return unavailableTrace(runId, 'nullwatch_unavailable'); + } + + const found = Array.isArray(listed?.items) && listed.items.some((item) => item?.run_id === runId); + if (!found) return unavailableTrace(runId, 'run_not_found'); + + try { + const detail = await api.getNullWatchRun(runId, { watch }); + const summary = normalizeRunSummary(detail, runId); + const spans = Array.isArray(detail?.spans) ? detail.spans : []; + const evals = Array.isArray(detail?.evals) ? detail.evals : []; + if (!summary && spans.length === 0 && evals.length === 0) return unavailableTrace(runId, 'empty_run_detail'); + return { + runId, + status: 'available', + summary, + spans, + evals, + loadedAtMs: Date.now(), + }; + } catch { + return unavailableTrace(runId, 'run_detail_unavailable'); + } +} + +function unavailableTrace(runId: string, reason: TraceHydrationUnavailableReason): TraceHydration { + return { + runId, + status: 'unavailable', + unavailableReason: reason, + message: unavailableTraceMessage(reason), + summary: null, + spans: [], + evals: [], + loadedAtMs: Date.now(), + }; +} + +function unavailableTraceMessage(reason: TraceHydrationUnavailableReason): string { + if (reason === 'no_running_nullwatch') return 'No running NullWatch instance'; + if (reason === 'status_unavailable') return 'NullHub status unavailable'; + if (reason === 'run_not_found') return 'No matching live run'; + if (reason === 'nullwatch_unavailable') return 'NullWatch run list unavailable'; + if (reason === 'run_detail_unavailable') return 'NullWatch run detail unavailable'; + return 'NullWatch returned no run detail'; +} + +export function missionTracePanelRunIds(snapshot: MissionControlState): string[] { + const ids: string[] = []; + addRunId(ids, snapshot.failure?.run_id || snapshot.failed_run_id); + addRunId(ids, snapshot.recovery?.run_id || snapshot.recovered_run_id); + return ids; +} + +function addRunId(ids: string[], runId: string | null | undefined) { + if (runId && !ids.includes(runId)) ids.push(runId); +} + +function normalizeRunSummary(detail: NullWatchRunDetail, runId: string): NullWatchRunSummary | null { + const summary = detail?.summary || detail?.run || null; + if (!summary || typeof summary !== 'object') return null; + return { + ...summary, + run_id: summary.run_id || runId, + }; +} diff --git a/ui/src/lib/nullboiler/routes.ts b/ui/src/lib/nullboiler/routes.ts new file mode 100644 index 0000000..a8e2550 --- /dev/null +++ b/ui/src/lib/nullboiler/routes.ts @@ -0,0 +1,50 @@ +import { BOILER_INSTANCE_QUERY_PARAM, getSelectedBoilerInstance } from "$lib/nullstack/backendSelection"; +import { encodePathSegment, withQueryParam } from "$lib/nullstack/path"; + +type NullBoilerRouteOptions = { + boilerInstance?: string; +}; + +export function withBoilerInstance(path: string, boilerInstance?: string): string { + const value = boilerInstance ?? getSelectedBoilerInstance(); + return withQueryParam(path, BOILER_INSTANCE_QUERY_PARAM, value); +} + +const nullboilerUiRoot = "/nullboiler"; +const nullboilerApiRoot = "/nullboiler"; +const workflowsBase = `${nullboilerUiRoot}/workflows`; +const runsBase = `${nullboilerUiRoot}/runs`; + +export const nullboilerUiRoutes = { + dashboard: (options?: NullBoilerRouteOptions) => withBoilerInstance(nullboilerUiRoot, options?.boilerInstance), + workflows: (options?: NullBoilerRouteOptions) => withBoilerInstance(workflowsBase, options?.boilerInstance), + newWorkflow: (options?: NullBoilerRouteOptions) => withBoilerInstance(`${workflowsBase}/new`, options?.boilerInstance), + workflow: (id: string, options?: NullBoilerRouteOptions) => withBoilerInstance(`${workflowsBase}/${encodePathSegment(id)}`, options?.boilerInstance), + runs: (options?: NullBoilerRouteOptions) => withBoilerInstance(runsBase, options?.boilerInstance), + run: (id: string, options?: NullBoilerRouteOptions) => withBoilerInstance(`${runsBase}/${encodePathSegment(id)}`, options?.boilerInstance), + runFork: (id: string, options?: NullBoilerRouteOptions) => withBoilerInstance(`${runsBase}/${encodePathSegment(id)}/fork`, options?.boilerInstance), +}; + +export const nullboilerApiPaths = { + workflows: () => `${nullboilerApiRoot}/workflows`, + workflow: (id: string) => `${nullboilerApiRoot}/workflows/${encodePathSegment(id)}`, + workflowValidate: (id: string) => `${nullboilerApiRoot}/workflows/${encodePathSegment(id)}/validate`, + workflowRun: (id: string) => `${nullboilerApiRoot}/workflows/${encodePathSegment(id)}/run`, + runs: () => `${nullboilerApiRoot}/runs`, + run: (id: string) => `${nullboilerApiRoot}/runs/${encodePathSegment(id)}`, + runCancel: (id: string) => `${nullboilerApiRoot}/runs/${encodePathSegment(id)}/cancel`, + runRetry: (id: string) => `${nullboilerApiRoot}/runs/${encodePathSegment(id)}/retry`, + runResume: (id: string) => `${nullboilerApiRoot}/runs/${encodePathSegment(id)}/resume`, + runReplay: (id: string) => `${nullboilerApiRoot}/runs/${encodePathSegment(id)}/replay`, + runState: (id: string) => `${nullboilerApiRoot}/runs/${encodePathSegment(id)}/state`, + runsFork: () => `${nullboilerApiRoot}/runs/fork`, + runCheckpoints: (runId: string) => `${nullboilerApiRoot}/runs/${encodePathSegment(runId)}/checkpoints`, + runCheckpoint: (runId: string, checkpointId: string) => `${nullboilerApiRoot}/runs/${encodePathSegment(runId)}/checkpoints/${encodePathSegment(checkpointId)}`, + runStream: (runId: string) => `${nullboilerApiRoot}/runs/${encodePathSegment(runId)}/stream`, + trackerStatus: () => `${nullboilerApiRoot}/tracker/status`, + trackerTasks: () => `${nullboilerApiRoot}/tracker/tasks`, + trackerStats: () => `${nullboilerApiRoot}/tracker/stats`, + trackerRefresh: () => `${nullboilerApiRoot}/tracker/refresh`, + workers: () => `${nullboilerApiRoot}/workers`, + worker: (id: string) => `${nullboilerApiRoot}/workers/${encodePathSegment(id)}`, +}; diff --git a/ui/src/lib/orchestration/backendSelection.ts b/ui/src/lib/nullstack/backendSelection.ts similarity index 90% rename from ui/src/lib/orchestration/backendSelection.ts rename to ui/src/lib/nullstack/backendSelection.ts index 3525a32..6d81f6d 100644 --- a/ui/src/lib/orchestration/backendSelection.ts +++ b/ui/src/lib/nullstack/backendSelection.ts @@ -1,5 +1,5 @@ -const SELECTED_BOILER_STORAGE_KEY = "nullhub.orchestration.boiler_instance"; -const SELECTED_TICKETS_STORAGE_KEY = "nullhub.orchestration.tickets_instance"; +const SELECTED_BOILER_STORAGE_KEY = "nullhub.nullboiler.instance"; +const SELECTED_TICKETS_STORAGE_KEY = "nullhub.nulltickets.instance"; export const BOILER_INSTANCE_QUERY_PARAM = "boiler_instance"; export const TICKETS_INSTANCE_QUERY_PARAM = "tickets_instance"; export const BOILER_INSTANCE_CHANGE_EVENT = "nullhub:boiler-instance-change"; @@ -56,7 +56,7 @@ function getUrlQueryParam(param: string): string { function syncCurrentBoilerUrl(value: string) { const location = currentLocation(); const history = currentHistory(); - if (!location || !history || !location.pathname.startsWith("/orchestration")) return; + if (!location || !history || !location.pathname.startsWith("/nullboiler")) return; const url = new URL(location.href); if (value) url.searchParams.set(BOILER_INSTANCE_QUERY_PARAM, value); @@ -67,7 +67,7 @@ function syncCurrentBoilerUrl(value: string) { function syncCurrentTicketsUrl(value: string) { const location = currentLocation(); const history = currentHistory(); - if (!location || !history || !location.pathname.startsWith("/orchestration/store")) return; + if (!location || !history || !location.pathname.startsWith("/nulltickets/store")) return; const url = new URL(location.href); if (value) url.searchParams.set(TICKETS_INSTANCE_QUERY_PARAM, value); diff --git a/ui/src/lib/nullstack/path.ts b/ui/src/lib/nullstack/path.ts new file mode 100644 index 0000000..3f072c0 --- /dev/null +++ b/ui/src/lib/nullstack/path.ts @@ -0,0 +1,24 @@ +export function encodePathSegment(value: string): string { + return encodeURIComponent(value); +} + +export function routePath(path: string): string { + const queryIndex = path.search(/[?#]/); + return queryIndex >= 0 ? path.slice(0, queryIndex) : path; +} + +export function withQueryParam(path: string, key: string, value: string): string { + const hashIndex = path.indexOf("#"); + const withoutHash = hashIndex >= 0 ? path.slice(0, hashIndex) : path; + const hash = hashIndex >= 0 ? path.slice(hashIndex) : ""; + const queryIndex = withoutHash.indexOf("?"); + const pathname = queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash; + const query = queryIndex >= 0 ? withoutHash.slice(queryIndex + 1) : ""; + const params = new URLSearchParams(query); + + if (value) params.set(key, value); + else params.delete(key); + + const nextQuery = params.toString(); + return `${pathname}${nextQuery ? `?${nextQuery}` : ""}${hash}`; +} diff --git a/ui/src/lib/nulltickets/routes.ts b/ui/src/lib/nulltickets/routes.ts new file mode 100644 index 0000000..9fd4e88 --- /dev/null +++ b/ui/src/lib/nulltickets/routes.ts @@ -0,0 +1,24 @@ +import { TICKETS_INSTANCE_QUERY_PARAM, getSelectedTicketsInstance } from "$lib/nullstack/backendSelection"; +import { encodePathSegment, withQueryParam } from "$lib/nullstack/path"; + +type NullTicketsRouteOptions = { + ticketsInstance?: string; +}; + +export function withTicketsInstance(path: string, ticketsInstance?: string): string { + const value = ticketsInstance ?? getSelectedTicketsInstance(); + return withQueryParam(path, TICKETS_INSTANCE_QUERY_PARAM, value); +} + +const nullticketsUiRoot = "/nulltickets"; +const nullticketsApiRoot = "/nulltickets"; +const storeBase = `${nullticketsApiRoot}/store`; + +export const nullticketsUiRoutes = { + store: (options?: NullTicketsRouteOptions) => withTicketsInstance(`${nullticketsUiRoot}/store`, options?.ticketsInstance), +}; + +export const nullticketsApiPaths = { + storeNamespace: (namespace: string) => `${storeBase}/${encodePathSegment(namespace)}`, + storeEntry: (namespace: string, key: string) => `${storeBase}/${encodePathSegment(namespace)}/${encodePathSegment(key)}`, +}; diff --git a/ui/src/lib/orchestration/routes.ts b/ui/src/lib/orchestration/routes.ts deleted file mode 100644 index 15fe46a..0000000 --- a/ui/src/lib/orchestration/routes.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - BOILER_INSTANCE_QUERY_PARAM, - TICKETS_INSTANCE_QUERY_PARAM, - getSelectedBoilerInstance, - getSelectedTicketsInstance, -} from "./backendSelection"; - -export function encodePathSegment(value: string): string { - return encodeURIComponent(value); -} - -type OrchestrationRouteOptions = { - boilerInstance?: string; - ticketsInstance?: string; -}; - -function setQueryParam(path: string, key: string, value: string): string { - const hashIndex = path.indexOf("#"); - const withoutHash = hashIndex >= 0 ? path.slice(0, hashIndex) : path; - const hash = hashIndex >= 0 ? path.slice(hashIndex) : ""; - const queryIndex = withoutHash.indexOf("?"); - const pathname = queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash; - const query = queryIndex >= 0 ? withoutHash.slice(queryIndex + 1) : ""; - const params = new URLSearchParams(query); - - if (value) params.set(key, value); - else params.delete(key); - - const nextQuery = params.toString(); - return `${pathname}${nextQuery ? `?${nextQuery}` : ""}${hash}`; -} - -export function routePath(path: string): string { - const queryIndex = path.search(/[?#]/); - return queryIndex >= 0 ? path.slice(0, queryIndex) : path; -} - -export function withBoilerInstance(path: string, boilerInstance?: string): string { - const value = boilerInstance ?? getSelectedBoilerInstance(); - return setQueryParam(path, BOILER_INSTANCE_QUERY_PARAM, value); -} - -export function withTicketsInstance(path: string, ticketsInstance?: string): string { - const value = ticketsInstance ?? getSelectedTicketsInstance(); - return setQueryParam(path, TICKETS_INSTANCE_QUERY_PARAM, value); -} - -const uiRoot = '/orchestration'; -const apiRoot = '/orchestration'; -const workflowsBase = `${uiRoot}/workflows`; -const runsBase = `${uiRoot}/runs`; -const storeBase = `${apiRoot}/store`; - -export const orchestrationUiRoutes = { - dashboard: (options?: OrchestrationRouteOptions) => withBoilerInstance(uiRoot, options?.boilerInstance), - workflows: (options?: OrchestrationRouteOptions) => withBoilerInstance(workflowsBase, options?.boilerInstance), - newWorkflow: (options?: OrchestrationRouteOptions) => withBoilerInstance(`${workflowsBase}/new`, options?.boilerInstance), - workflow: (id: string, options?: OrchestrationRouteOptions) => withBoilerInstance(`${workflowsBase}/${encodePathSegment(id)}`, options?.boilerInstance), - runs: (options?: OrchestrationRouteOptions) => withBoilerInstance(runsBase, options?.boilerInstance), - run: (id: string, options?: OrchestrationRouteOptions) => withBoilerInstance(`${runsBase}/${encodePathSegment(id)}`, options?.boilerInstance), - runFork: (id: string, options?: OrchestrationRouteOptions) => withBoilerInstance(`${runsBase}/${encodePathSegment(id)}/fork`, options?.boilerInstance), - store: (options?: OrchestrationRouteOptions) => withTicketsInstance(`${uiRoot}/store`, options?.ticketsInstance), -}; - -export const orchestrationApiPaths = { - workflows: () => `${apiRoot}/workflows`, - workflow: (id: string) => `${apiRoot}/workflows/${encodePathSegment(id)}`, - workflowValidate: (id: string) => `${apiRoot}/workflows/${encodePathSegment(id)}/validate`, - workflowRun: (id: string) => `${apiRoot}/workflows/${encodePathSegment(id)}/run`, - runs: () => `${apiRoot}/runs`, - run: (id: string) => `${apiRoot}/runs/${encodePathSegment(id)}`, - runCancel: (id: string) => `${apiRoot}/runs/${encodePathSegment(id)}/cancel`, - runRetry: (id: string) => `${apiRoot}/runs/${encodePathSegment(id)}/retry`, - runResume: (id: string) => `${apiRoot}/runs/${encodePathSegment(id)}/resume`, - runReplay: (id: string) => `${apiRoot}/runs/${encodePathSegment(id)}/replay`, - runState: (id: string) => `${apiRoot}/runs/${encodePathSegment(id)}/state`, - runsFork: () => `${apiRoot}/runs/fork`, - runCheckpoints: (runId: string) => `${apiRoot}/runs/${encodePathSegment(runId)}/checkpoints`, - runCheckpoint: (runId: string, checkpointId: string) => `${apiRoot}/runs/${encodePathSegment(runId)}/checkpoints/${encodePathSegment(checkpointId)}`, - runStream: (runId: string) => `${apiRoot}/runs/${encodePathSegment(runId)}/stream`, - trackerStatus: () => `${apiRoot}/tracker/status`, - trackerTasks: () => `${apiRoot}/tracker/tasks`, - trackerStats: () => `${apiRoot}/tracker/stats`, - trackerRefresh: () => `${apiRoot}/tracker/refresh`, - workers: () => `${apiRoot}/workers`, - worker: (id: string) => `${apiRoot}/workers/${encodePathSegment(id)}`, - storeNamespace: (namespace: string) => `${storeBase}/${encodePathSegment(namespace)}`, - storeEntry: (namespace: string, key: string) => `${storeBase}/${encodePathSegment(namespace)}/${encodePathSegment(key)}`, -}; diff --git a/ui/src/routes/instances/[component]/[name]/+page.svelte b/ui/src/routes/instances/[component]/[name]/+page.svelte index 9f3c84f..cbc9a77 100644 --- a/ui/src/routes/instances/[component]/[name]/+page.svelte +++ b/ui/src/routes/instances/[component]/[name]/+page.svelte @@ -12,17 +12,14 @@ import NullBoilerPanel from "$lib/components/NullBoilerPanel.svelte"; import NullTicketsPanel from "$lib/components/NullTicketsPanel.svelte"; import { api, type ApiRequestError } from "$lib/api/client"; - import { - orchestrationUiRoutes, - withBoilerInstance, - withTicketsInstance, - } from "$lib/orchestration/routes"; + import { nullboilerUiRoutes, withBoilerInstance } from "$lib/nullboiler/routes"; + import { nullticketsUiRoutes, withTicketsInstance } from "$lib/nulltickets/routes"; import { getSelectedBoilerInstance, getSelectedTicketsInstance, setSelectedBoilerInstance, setSelectedTicketsInstance, - } from "$lib/orchestration/backendSelection"; + } from "$lib/nullstack/backendSelection"; let component = $derived($page.params.component); let name = $derived($page.params.name); @@ -630,7 +627,7 @@ async function openTicketsStore() { if (component !== "nulltickets") return; setSelectedTicketsInstance(name); - await goto(withTicketsInstance(orchestrationUiRoutes.store(), name)); + await goto(withTicketsInstance(nullticketsUiRoutes.store(), name)); } async function createTicketTask() { @@ -1048,7 +1045,7 @@ {#if component === "nullwatch"} - Observability + NullWatch {/if} Open ObservabilityOpen NullWatch {/if} @@ -1339,8 +1336,8 @@ Open ObservabilityOpen NullWatch {:else} @@ -1447,13 +1444,13 @@
diff --git a/ui/src/routes/mission-control/+page.svelte b/ui/src/routes/mission-control/+page.svelte new file mode 100644 index 0000000..ab3688a --- /dev/null +++ b/ui/src/routes/mission-control/+page.svelte @@ -0,0 +1,1479 @@ + + +
+
+
+

Mission Control

+

{mission?.headline || 'Loading mission state...'}

+
+
+ + + + + +
+
+ + {#if error} + + {/if} + + {#if loading && !mission} +
Loading mission...
+ {:else if mission} +
+
+ Mode + {modeLabel} +
+
+ Scenario + {mission.scenario_id} +
+
+ Schema + v{mission.schema_version} +
+
+ Polling + {activePoll ? 'live' : 'idle'} +
+
+ +
+
+ Status + {mission.status} +
+
+ Phase + {mission.phase} +
+
+ Elapsed + {formatDuration(mission.elapsed_ms)} +
+
+ Run + {mission.active_run_id || '-'} +
+
+ + {#if savedReplays.length > 0 || savedReplaysLoading || savedReplaysError} +
+
+

Saved Replays

+ {savedReplaysLoading ? 'loading' : savedReplaysError ? 'unavailable' : `${savedReplays.length} stored`} +
+ {#if savedReplaysError} +

{savedReplaysError}

+ {/if} + {#if savedReplays.length > 0} +
+ {#each savedReplays.slice(0, 4) as replay} + + {/each} +
+ {/if} +
+ {/if} + +
+ {#each phaseMilestones as beat} +
+ {beat.time} + {beat.title} +

{beat.detail}

+
+ {/each} +
+ +
+
+
+ +
+
+

Live NullBoiler

+ {mission.progress}% +
+
+ {#each nodes as node, index} +
+
+ {node.kind} + {node.label} +
+ {#if index < nodes.length - 1} +
+ {/if} +
+ {/each} +
+
+ +
+
+
+

Agent Board

+ {agents.length} +
+
+ {#each agents as agent} +
+
+ {agent.role} + {agent.id} +
+

{agent.current_step}

+ {agent.status} +
+ {/each} +
+
+ +
+
+

Telemetry

+ {displayTelemetry.verdict || '-'} +
+
+
Runs{displayTelemetry.runs || 0}
+
Spans{displayTelemetry.spans || 0}
+
Evals{displayTelemetry.evals || 0}
+
Errors 0}>{displayTelemetry.errors || 0}
+
Tokens{formatTokens(displayTelemetry.total_tokens)}
+
Cost{formatCost(displayTelemetry.total_cost_usd)}
+
+ +
+
+ Traceability + {traceSourceSummary()} + {tracePanelNote()} +
+ {#if failedTraceAvailable} + Failed run{traceSuffix(failedTrace)} + {:else if failedRunId && traceHydrating} + Checking failed run + {:else if failedTrace?.message} + {failedTrace.message} + {:else if failedRunId} + Failed run unavailable + {:else} + Failed pending + {/if} + {#if recoveredTraceAvailable} + Recovered run{traceSuffix(recoveredTrace)} + {:else if recoveredRunId && traceHydrating} + Checking recovered run + {:else if recoveredTrace?.message} + {recoveredTrace.message} + {:else if recoveredRunId} + Recovered run unavailable + {:else} + Recovery pending + {/if} +
+ +
+
+ Workflow + {workflowSourceSummary()} + {workflowPanelNote()} +
+ {#if failedWorkflowRun} + Failed workflow{workflowRunSuffix(failedWorkflowRun)} + {:else if failedRunId || mission.failure} + Failed workflow unavailable + {:else} + Workflow pending + {/if} + {#if recoveredWorkflowRun} + Recovered workflow{workflowRunSuffix(recoveredWorkflowRun)} + {:else if recoveredRunId || mission.recovery} + Recovered workflow unavailable + {:else} + Recovery pending + {/if} +
+ + {#if mission.failure} +
+ Failure + {mission.failure.failed_step} +

{mission.failure.error_message}

+ {workflowCheckpoint?.id || mission.failure.checkpoint_id} + {#if workflowCheckpoint} +

+ NullBoiler checkpoint {workflowCheckpointLabel(workflowCheckpoint)} + {#if workflowCheckpointMetadata(workflowCheckpoint)} + · {workflowCheckpointMetadata(workflowCheckpoint)} + {/if} +

+ {#if failedWorkflowRun} + Open failed workflow + {/if} + {/if} + {#if failedTraceAvailable} + Open failed trace + {/if} + {#if failedTrace || traceHydrating} +
+
+ {traceSourceLabel(failedTrace)} + {traceVerdict(failedTrace) || runVerdict('failed')} +
+ {#if failedTraceAvailable} +
+
Spans
{spanCount(failedTrace)}
+
Evals
{evalCount(failedTrace)}
+
Errors
{errorCount(failedTrace)}
+
+ {#if primaryErrorText(failedTrace)} +

{primaryErrorText(failedTrace)}

+ {/if} + {#if primaryEvalText(failedTrace, 'tool_success')} +

{primaryEvalText(failedTrace, 'tool_success')}

+ {/if} + {:else if failedTrace?.message} +

{failedTrace.message}

+ {/if} +
+ {/if} +
+ {/if} + + {#if mission.recovery} +
+ Recovery + {mission.recovery.status} +

{mission.recovery.human_instruction}

+ {mission.recovery.run_id} + {#if recoveredWorkflowRun} +

NullBoiler run {recoveredWorkflowRun.run_id}{workflowRunSuffix(recoveredWorkflowRun)}

+ Open recovered workflow + {/if} + {#if recoveredTraceAvailable} + Open recovered trace + {/if} + {#if recoveredTrace || traceHydrating} +
+
+ {traceSourceLabel(recoveredTrace)} + {traceVerdict(recoveredTrace) || runVerdict('recovered')} +
+ {#if recoveredTraceAvailable} +
+
Spans
{spanCount(recoveredTrace)}
+
Evals
{evalCount(recoveredTrace)}
+
Errors
{errorCount(recoveredTrace)}
+
+ {#if primaryErrorText(recoveredTrace)} +

{primaryErrorText(recoveredTrace)}

+ {/if} + {#if primaryEvalText(recoveredTrace, 'tool_success')} +

{primaryEvalText(recoveredTrace, 'tool_success')}

+ {/if} + {:else if recoveredTrace?.message} +

{recoveredTrace.message}

+ {/if} +
+ {/if} +
+ {/if} +
+
+ + {#if replayComparison} + + {/if} + +
+
+

Mission Timeline

+ {events.filter((event: MissionControlEvent) => event.status !== 'pending').length}/{events.length} +
+
+ {#each events as event} +
+
+
+
+ {event.title} + {formatDuration(event.at_ms)} +
+
+ {event.source} + {event.level} + {#if event.trace} + + {event.trace.kind}: {traceLabel(event.trace)} + + {/if} +
+

{event.detail}

+
+
+ {/each} +
+
+ {/if} +
+ + diff --git a/ui/src/routes/orchestration/+page.svelte b/ui/src/routes/nullboiler/+page.svelte similarity index 93% rename from ui/src/routes/orchestration/+page.svelte rename to ui/src/routes/nullboiler/+page.svelte index 30a1417..0d84ce2 100644 --- a/ui/src/routes/orchestration/+page.svelte +++ b/ui/src/routes/nullboiler/+page.svelte @@ -1,9 +1,9 @@
-

Orchestration

+

NullBoiler

{ loading = true; error = null; void loadRuns(); }} /> - New Run + New Run
@@ -99,8 +99,8 @@
Loading runs...
{:else if runs.length === 0}
-

> No orchestration runs yet.

- Create a Workflow +

> No NullBoiler runs yet.

+ Create a Workflow
{:else}
@@ -136,7 +136,7 @@
{#if runs.length > 20} {/if}
diff --git a/ui/src/routes/orchestration/runs/+page.svelte b/ui/src/routes/nullboiler/runs/+page.svelte similarity index 96% rename from ui/src/routes/orchestration/runs/+page.svelte rename to ui/src/routes/nullboiler/runs/+page.svelte index 8a78880..fd940b5 100644 --- a/ui/src/routes/orchestration/runs/+page.svelte +++ b/ui/src/routes/nullboiler/runs/+page.svelte @@ -1,8 +1,8 @@ diff --git a/ui/src/routes/orchestration/runs/[id]/+page.svelte b/ui/src/routes/nullboiler/runs/[id]/+page.svelte similarity index 88% rename from ui/src/routes/orchestration/runs/[id]/+page.svelte rename to ui/src/routes/nullboiler/runs/[id]/+page.svelte index a715688..d0044ac 100644 --- a/ui/src/routes/orchestration/runs/[id]/+page.svelte +++ b/ui/src/routes/nullboiler/runs/[id]/+page.svelte @@ -1,14 +1,14 @@
- Runs + Runs / {(id || '').slice(0, 8)} {#if run} diff --git a/ui/src/routes/orchestration/runs/[id]/fork/+page.svelte b/ui/src/routes/nullboiler/runs/[id]/fork/+page.svelte similarity index 90% rename from ui/src/routes/orchestration/runs/[id]/fork/+page.svelte rename to ui/src/routes/nullboiler/runs/[id]/fork/+page.svelte index aaaf5fd..3c07bbb 100644 --- a/ui/src/routes/orchestration/runs/[id]/fork/+page.svelte +++ b/ui/src/routes/nullboiler/runs/[id]/fork/+page.svelte @@ -2,11 +2,11 @@ import { onMount } from 'svelte'; import { page } from '$app/stores'; import { goto } from '$app/navigation'; - import { api } from '$lib/api/client'; - import { orchestrationUiRoutes } from '$lib/orchestration/routes'; - import BoilerInstanceSelector from '$lib/components/orchestration/BoilerInstanceSelector.svelte'; - import CheckpointTimeline from '$lib/components/orchestration/CheckpointTimeline.svelte'; - import StateInspector from '$lib/components/orchestration/StateInspector.svelte'; + import { nullBoilerApi } from '$lib/api/client'; + import { nullboilerUiRoutes } from '$lib/nullboiler/routes'; + import BoilerInstanceSelector from '$lib/components/nullboiler/BoilerInstanceSelector.svelte'; + import CheckpointTimeline from '$lib/components/nullboiler/CheckpointTimeline.svelte'; + import StateInspector from '$lib/components/nullboiler/StateInspector.svelte'; let runId = $derived($page.params.id); @@ -26,7 +26,7 @@ selectedCp = ''; selectedState = null; try { - checkpoints = await api.listCheckpoints(runId) || []; + checkpoints = await nullBoilerApi.listCheckpoints(runId) || []; if (checkpoints.length > 0) { await selectCheckpoint(checkpoints[checkpoints.length - 1].id); } @@ -44,7 +44,7 @@ async function selectCheckpoint(cpId: string) { selectedCp = cpId; try { - const cp = await api.getCheckpoint(runId, cpId); + const cp = await nullBoilerApi.getCheckpoint(runId, cpId); selectedState = cp?.state || cp; } catch (e) { error = (e as Error).message; @@ -67,9 +67,9 @@ error = null; try { const overrides = JSON.parse(overridesJson); - const result = await api.forkRun(selectedCp, Object.keys(overrides).length > 0 ? overrides : undefined); + const result = await nullBoilerApi.forkRun(selectedCp, Object.keys(overrides).length > 0 ? overrides : undefined); if (result?.id) { - await goto(orchestrationUiRoutes.run(result.id)); + await goto(nullboilerUiRoutes.run(result.id)); } } catch (e) { error = (e as Error).message; @@ -79,7 +79,7 @@ } function runHref(id: string): string { - return orchestrationUiRoutes.run(id); + return nullboilerUiRoutes.run(id); } diff --git a/ui/src/routes/orchestration/workflows/+page.svelte b/ui/src/routes/nullboiler/workflows/+page.svelte similarity index 93% rename from ui/src/routes/orchestration/workflows/+page.svelte rename to ui/src/routes/nullboiler/workflows/+page.svelte index 1b2e15a..07e3dc9 100644 --- a/ui/src/routes/orchestration/workflows/+page.svelte +++ b/ui/src/routes/nullboiler/workflows/+page.svelte @@ -1,9 +1,9 @@ @@ -48,7 +48,7 @@

Workflows

{ loading = true; error = null; void loadWorkflows(); }} /> - + New Workflow + + New Workflow
@@ -61,7 +61,7 @@ {:else if workflows.length === 0}

> No workflows defined yet.

- Create Workflow + Create Workflow
{:else}
diff --git a/ui/src/routes/orchestration/workflows/[id]/+page.svelte b/ui/src/routes/nullboiler/workflows/[id]/+page.svelte similarity index 88% rename from ui/src/routes/orchestration/workflows/[id]/+page.svelte rename to ui/src/routes/nullboiler/workflows/[id]/+page.svelte index 15d19c5..66ec935 100644 --- a/ui/src/routes/orchestration/workflows/[id]/+page.svelte +++ b/ui/src/routes/nullboiler/workflows/[id]/+page.svelte @@ -2,11 +2,11 @@ import { onMount } from 'svelte'; import { page } from '$app/stores'; import { goto } from '$app/navigation'; - import { api } from '$lib/api/client'; - import { orchestrationUiRoutes } from '$lib/orchestration/routes'; - import BoilerInstanceSelector from '$lib/components/orchestration/BoilerInstanceSelector.svelte'; - import GraphViewer from '$lib/components/orchestration/GraphViewer.svelte'; - import WorkflowJsonEditor from '$lib/components/orchestration/WorkflowJsonEditor.svelte'; + import { nullBoilerApi } from '$lib/api/client'; + import { nullboilerUiRoutes } from '$lib/nullboiler/routes'; + import BoilerInstanceSelector from '$lib/components/nullboiler/BoilerInstanceSelector.svelte'; + import GraphViewer from '$lib/components/nullboiler/GraphViewer.svelte'; + import WorkflowJsonEditor from '$lib/components/nullboiler/WorkflowJsonEditor.svelte'; let id = $derived($page.params.id); let isNew = $derived(id === 'new'); @@ -40,7 +40,7 @@ } try { - const wf = await api.getWorkflow(id); + const wf = await nullBoilerApi.getWorkflow(id); parsedWorkflow = wf; jsonValue = JSON.stringify(wf, null, 2); } catch (e) { @@ -77,7 +77,7 @@ validating = true; validationResult = null; try { - const result = await api.validateWorkflow(id); + const result = await nullBoilerApi.validateWorkflow(id); validationResult = result; } catch (e) { validationResult = { valid: false, errors: [(e as Error).message] }; @@ -92,10 +92,10 @@ error = null; try { if (isNew) { - const result = await api.createWorkflow(parsedWorkflow); - await goto(orchestrationUiRoutes.workflow(result.id || parsedWorkflow.id)); + const result = await nullBoilerApi.createWorkflow(parsedWorkflow); + await goto(nullboilerUiRoutes.workflow(result.id || parsedWorkflow.id)); } else { - await api.updateWorkflow(id, parsedWorkflow); + await nullBoilerApi.updateWorkflow(id, parsedWorkflow); } } catch (e) { error = (e as Error).message; @@ -107,9 +107,9 @@ async function run() { if (parseError || isNew) return; try { - const result = await api.runWorkflow(id, {}); + const result = await nullBoilerApi.runWorkflow(id, {}); if (result?.id) { - await goto(orchestrationUiRoutes.run(result.id)); + await goto(nullboilerUiRoutes.run(result.id)); } } catch (e) { error = (e as Error).message; @@ -120,7 +120,7 @@
- Workflows + Workflows / {isNew ? 'New Workflow' : (parsedWorkflow?.name || id)}
diff --git a/ui/src/routes/orchestration/store/+page.svelte b/ui/src/routes/nulltickets/store/+page.svelte similarity index 96% rename from ui/src/routes/orchestration/store/+page.svelte rename to ui/src/routes/nulltickets/store/+page.svelte index 7824c9a..5ed7c69 100644 --- a/ui/src/routes/orchestration/store/+page.svelte +++ b/ui/src/routes/nulltickets/store/+page.svelte @@ -1,7 +1,7 @@