diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66f5d9ff..08cffb92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,10 +2,15 @@ name: CI on: pull_request: + merge_group: push: branches: - main +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read @@ -18,8 +23,8 @@ jobs: matrix: include: - os: ubuntu-latest - label: ubuntu canonical - ci-target: ci + label: ubuntu required shared-linux perf + ci-target: ci-required-shared-linux - os: macos-latest label: macos portability ci-target: ci-portability @@ -67,6 +72,74 @@ jobs: exit 1 fi + formal-security-kernel: + name: Formal security kernel TLA + runs-on: ubuntu-latest + env: + NIX_CONFIG: | + substituters = https://cache.nixos.org + trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Detect TLA-relevant PR changes + id: tla_changes + shell: bash + run: | + if [ "${{ github.event_name }}" != "pull_request" ]; then + echo "required=true" >> "$GITHUB_OUTPUT" + echo "mode=all" >> "$GITHUB_OUTPUT" + exit 0 + fi + + changed_files="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")" + if grep -E '^(formal/tla/|tools/tlccheck/|justfile|\.github/workflows/ci\.yml)' <<< "$changed_files"; then + echo "required=true" >> "$GITHUB_OUTPUT" + echo "mode=all" >> "$GITHUB_OUTPUT" + elif grep -E '^(protocol/|internal/|cmd/|runner/|docs/trust-boundaries\.md|runecontext/standards/security/|runecontext/standards/global/|runecontext/changes/CHG-2026-015-[^/]+/)' <<< "$changed_files"; then + echo "required=true" >> "$GITHUB_OUTPUT" + echo "mode=core" >> "$GITHUB_OUTPUT" + else + echo "required=false" >> "$GITHUB_OUTPUT" + echo "mode=skip" >> "$GITHUB_OUTPUT" + fi + + - name: Report TLA skip + if: github.event_name == 'pull_request' && steps.tla_changes.outputs.required != 'true' + run: echo "No security-kernel-relevant changes detected; TLA model check skipped for this PR diff." + + - name: Install Nix + if: steps.tla_changes.outputs.required == 'true' + uses: DeterminateSystems/nix-installer-action@c5a866b6ab867e88becbed4467b93592bce69f8a # v21 + + - name: Cache Nix store + if: steps.tla_changes.outputs.required == 'true' + uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13 + with: + use-flakehub: false + + - name: Run PR core TLA model check + if: github.event_name == 'pull_request' && steps.tla_changes.outputs.mode == 'core' + run: nix develop --no-write-lock-file -c just model-check-core + + - name: Run full TLA model check + if: steps.tla_changes.outputs.mode == 'all' + run: nix develop --no-write-lock-file -c just model-check + + - name: Verify repository unchanged after TLA check + if: steps.tla_changes.outputs.required == 'true' + run: | + git diff --exit-code + untracked="$(git ls-files --others --exclude-standard)" + if [ -n "$untracked" ]; then + echo "Untracked files found after TLA check:" + echo "$untracked" + exit 1 + fi + windows: name: Windows portability (Node ${{ matrix.node-version }}) runs-on: windows-latest diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml new file mode 100644 index 00000000..bd0431e2 --- /dev/null +++ b/.github/workflows/dco.yml @@ -0,0 +1,52 @@ +name: DCO + +on: + pull_request: + merge_group: + +permissions: + contents: read + +jobs: + dco: + name: DCO + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Validate DCO sign-offs + shell: bash + run: | + set -euo pipefail + + if [ "${{ github.event_name }}" = "pull_request" ]; then + range="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" + else + range="HEAD^1..HEAD" + fi + + commits="$(git rev-list --reverse "$range")" + if [ -z "$commits" ]; then + echo "No commits found in range $range" + exit 1 + fi + + missing=0 + while IFS= read -r commit; do + [ -n "$commit" ] || continue + if ! git log -1 --format=%B "$commit" | grep -qi '^Signed-off-by: '; then + echo "Missing Signed-off-by trailer in commit $commit" + git log -1 --format='subject: %s%nauthor: %an <%ae>' "$commit" + missing=1 + fi + done <<< "$commits" + + if [ "$missing" -ne 0 ]; then + echo "One or more commits are missing DCO sign-off trailers." + exit 1 + fi + + echo "All commits in $range include DCO sign-off trailers." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb619366..6d59a59b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,7 +60,7 @@ The canonical local workflow uses Nix + `just`: - Prerequisite: Nix `>= 2.18` - Optional auto-entry: `direnv` + `nix-direnv` - Canonical command surface: `just` -- CI runs one canonical `just ci` lane plus portability lanes (`just ci-portability`) to avoid duplicating model-check runtime cost across every matrix leg +- CI runs fast canonical checks plus a dedicated Linux formal-security gate to avoid duplicating model-check runtime cost across every matrix leg ### Use the dev shell manually diff --git a/README.md b/README.md index eda8dacc..7928c894 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # RuneCode — Security-first AI coding: isolated execution, signed, auditable [![CI](https://github.com/runecode-ai/runecode/actions/workflows/ci.yml/badge.svg)](https://github.com/runecode-ai/runecode/actions/workflows/ci.yml) -[![Status: alpha.9 in progress](https://img.shields.io/badge/status-alpha.9%20in%20progress-orange)](runecontext/project/roadmap.md) +[![Status: alpha.11 in progress](https://img.shields.io/badge/status-alpha.11%20in%20progress-orange)](runecontext/project/roadmap.md) [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) RuneCode is a security-first agentic automation platform for software engineering. @@ -9,8 +9,9 @@ It treats isolation and cryptographic provenance as co-equal pillars: work runs ## Status -The latest published release is `v0.1.0-alpha.7`, and the repository mainline already includes `v0.1.0-alpha.9` work in progress. -RuneCode remains pre-production: the signed, tag-driven release pipeline exists, but the shipped Go binaries are still scaffold-heavy and not feature-complete. +The latest published release is `v0.1.0-alpha.7`, and the repository mainline already includes `v0.1.0-alpha.11` work in progress. +RuneCode remains pre-production: the signed, tag-driven release pipeline exists, and the current shipped surface is a local-first beta-hardening slice rather than the full long-term product. +Today that supported slice is the repo-scoped local lifecycle plus verified RuneContext project-substrate lifecycle, change/spec drafting, reviewed draft promote/apply, approved implementation, and inspectable audit/evidence surfaces. ## Why RuneCode @@ -148,7 +149,7 @@ This quick path verifies signed checksums and the signed archive before install. - Workflow/process planning schemas and fixtures, plus trusted Go compilation, persistence, and selection of immutable `RunPlan` authority that binds reviewed workflow selection, authoritative process DAG shape, executor bindings, deterministic gate definitions, dependency edges, and compiled runtime entries into one broker-owned execution contract - A first-party RuneContext workflow pack with broker-owned routing for `change_draft`, `spec_draft`, `draft_promote_apply`, and `approved_change_implementation`, where drafting remains artifact-first, approved implementation binds one exact reviewed `implementation_input_set`, and shared-workspace execution stays at one active mutation-bearing run per authoritative repository root in `v0` - Deterministic gate contracts and reporting families for gate planning, runner checkpoint/result reporting, gate checkpoint/result reporting, and gate evidence persistence, with stored evidence bound back to the active plan, workflow/process definition hashes, policy context hash, and validated project context digest -- A thin untrusted runner kernel foundation that loads broker-compiled `RunPlan` data from the shared schema bundle, persists plan-bound journal/snapshot durable state, replays approval waits and recovery state fail closed, schedules plan entries, and emits typed reports back to the broker +- A thin untrusted runner kernel foundation that loads broker-compiled `RunPlan` data from the shared schema bundle, persists plan-bound journal/snapshot durable state, replays approval waits and recovery state fail closed, schedules plan entries, emits typed reports back to the broker, and supports a plan-first product launch path that fails closed on missing broker transport or schema inputs while confining `--plan-file` and `--state-root` under a trusted `--plan-root` - A narrow internal runner runtime seam for local checkpoint, wait, and resume mechanics without making runner-local state, third-party runtimes, or framework checkpoints authoritative - MVP artifact data classes and an `ArtifactPolicy` schema family anchoring flow-matrix, approval-promotion, quota, and retention/GC controls - A trusted local artifact store with immutable hash-addressed artifact persistence, broker-facing flow checks, quota enforcement, retention/GC, self-contained signed backup bundle export and fail-closed restore, approval records, persisted policy decisions, and audit event recording for artifact and approval actions @@ -171,13 +172,14 @@ This quick path verifies signed checksums and the signed archive before install. - Broker-projected backend posture state and approval-mediated instance posture changes, including the active launcher `instance_id`, selected `backend_kind`, reduced-assurance cues, per-backend availability, and policy/approval linkage for posture changes - A trusted launcher daemon/service plus a Linux-first microVM/QEMU/KVM MVP vertical slice and a Linux-only explicit-opt-in container backend slice for offline `workspace` launches, including a deterministic `runecode-launcher serve --hello-world` path for end-to-end launcher->broker runtime reporting - Signed runtime-image and runtime-toolchain identity contracts, typed verifier-authority state, trusted admission into a launcher-private verified runtime cache, and fail-closed launch from verified local assets rather than mutable host paths or ad hoc launch-time synthesis -- Durable launcher runtime evidence persistence and broker-derived authoritative runtime projection for `backend_kind`, `isolation_assurance_level`, `provisioning_posture`, lifecycle, terminal state, and runtime attestation support or verification posture from persisted evidence rather than transient launcher state -- Broker-owned runtime audit emission for `runtime_launch_admission`, `runtime_launch_denied`, `isolate_session_started`, and `isolate_session_bound`, with reference-heavy payloads bound to persisted launcher evidence digests -- Checked-in bounded TLA+ security-kernel artifacts plus deterministic TLC model-checking wired into `just model-check` and `just ci` +- Durable launcher runtime evidence persistence and broker-derived authoritative runtime projection for `backend_kind`, `isolation_assurance_level`, `provisioning_posture`, lifecycle, terminal state, and runtime attestation support or verification posture from persisted evidence rather than transient launcher state, with supported `attested` posture only earned after secure-session validation, post-handshake runtime evidence collection, and trusted verification +- Broker-owned runtime audit emission for `runtime_launch_admission`, `runtime_launch_denied`, `isolate_session_started`, and `isolate_session_bound`, with reference-heavy payloads bound to persisted launcher evidence digests and later attestation linkage added from persisted post-handshake evidence rather than optimistic launch-time fields +- Checked-in bounded TLA+ security-kernel artifacts plus deterministic TLC model-checking wired into `just model-check`, `just model-check-core`, and `just ci` +- Reviewed machine-consumed performance contracts under `tools/perfcontracts/`, deterministic performance fixtures and harnesses for TUI, broker, runner or workflow, gateway or dependency or audit or protocol surfaces, and a required shared-Linux CI gate that currently enforces only the checked-in `required_shared_linux` subset while launcher, attestation, and external-anchor surfaces remain informational or `contract_pending_dependency` Still incremental / not implemented end-to-end yet: - Secure-storage posture projection and broader provider auth modes remain incremental, but direct-credential provider setup and execution now exist for OpenAI-compatible and Anthropic-compatible endpoints on the shared provider substrate -- The primary secure path now includes signed runtime-image and toolchain admission into a verified local cache for Linux-first launcher operation. Container backend support still exists as a Linux-only explicit-opt-in reduced-assurance MVP for offline `workspace` launches; broader role coverage, non-Linux runtime paths, and further hardening/verification remain future work +- The primary secure path now includes signed runtime-image and toolchain admission into a verified local cache for Linux-first launcher operation, with the earlier post-handshake attestation ordering gap now closed for supported `attested` posture. Container backend support still exists as a Linux-only explicit-opt-in reduced-assurance MVP for offline `workspace` launches; broader role coverage, non-Linux runtime paths, and further hardening/verification remain future work - The broker and artifact store now implement local runtime behavior, but the overall system is still early alpha and not production-ready - Roadmap: `runecontext/project/roadmap.md` @@ -240,8 +242,12 @@ Common commands: just fmt just lint just model-check +just model-check-core +just model-check-replay just test +just ci-fast just ci +just ci-required-shared-linux ``` Useful protocol-specific checks: @@ -254,12 +260,14 @@ cd runner && npm test cd runner && npm run boundary-check ``` -These checks are also covered by `just ci`. +These checks are covered by `just ci`, while the required shared-Linux performance-contract subset runs in the dedicated `just ci-required-shared-linux` lane rather than every local `just ci` run. Formal model checking entrypoint: ```sh just model-check +just model-check-core +just model-check-replay ``` Optional: enable automatic dev-shell entry with `direnv` + `nix-direnv`: @@ -276,15 +284,16 @@ just ci ## Components -The Go binaries currently shipped by the release pipeline remain pre-production and intentionally do not expose the full production system surface. +The Go binaries currently shipped by the release pipeline remain pre-production and intentionally expose a local-first supported slice rather than the full production system surface. Alongside that still-incremental surface, the repository already includes working foundations with: - manifest-verified schemas and registries - cross-language fixture validation - canonicalization/hash golden tests - runner trust-boundary static checks -- a trusted full-screen `runecode-tui` workbench with dashboard/chat/runs/approvals/Action Center/artifacts/audit/status/model-providers/git-setup/git-remote routes, shell-owned pane composition, session quick switching, a configurable `space`-default leader surface, bottom-left `:` command mode, one unified action graph for help/discovery/leader/command aliases, a visible quit action plus double-press `ctrl+c` emergency escape hatch, typed watch-backed live activity, chat execution progress derived from broker-owned session execution trigger plus turn-execution watch state, selection-mode copy ergonomics, broker-owned direct-credential provider setup with masked secret entry, and local-only layout/theme persistence +- a trusted full-screen `runecode-tui` workbench with dashboard/chat/runs/approvals/Action Center/artifacts/audit/status/model-providers and other admin routes, shell-owned pane composition, session quick switching, a configurable `space`-default leader surface, bottom-left `:` command mode, one unified action graph for help/discovery/leader/command aliases, a visible quit action plus double-press `ctrl+c` emergency escape hatch, typed watch-backed live activity, chat execution progress derived from broker-owned session execution trigger plus turn-execution watch state, selection-mode copy ergonomics, broker-owned direct-credential provider setup with masked secret entry, and local-only layout/theme persistence - the TUI status route now surfaces broker-owned project-substrate posture plus adopt, init, and upgrade actions without making the TUI itself authoritative +- a broker-owned local-first RuneContext workflow slice covering project-substrate inspect/adopt/init/upgrade, `change_draft`, `spec_draft`, reviewed `draft_promote_apply`, and `approved_change_implementation`, with runs, artifacts, approvals, and audit/evidence surfaces linked back to the authoritative plan - a trusted local artifact store and broker CLI for artifact put/get/head/list, flow checks, excerpt promotion and revocation, run-status updates, GC, and self-contained signed backup bundle export or fail-closed restore that preserves runtime evidence, lifecycle state, and related durable attestation state - a trusted local audit ledger plus broker/auditd CLI surfaces for audit readiness, audit verification inspection, audit record inspection, audit record inclusion lookup, evidence snapshots and retention review, verifier-friendly evidence-bundle manifest generation, streaming bundle export, offline bundle verification, explicit audit anchoring over signed segment seals, and external-anchor evidence plus sidecar persistence used by verification and projections - a broker local IPC API and CLI read/action surfaces for run list/detail, session list/detail/message append/execution trigger/session watch, approval list/detail/resolve, policy-backed artifact reads, audit timeline/record inspection, audit record inclusion lookup, audit evidence snapshot/retention review/bundle manifest/bundle export/offline verify, audit anchoring presence/action, audit verification/readiness, external-anchor mutation prepare/get/issue-execute-lease/execute, trusted-contract import, version inspection, structured log streaming, broker-projected backend posture get/change operations, project-substrate posture/get/adopt/init/upgrade operations with preview-digest-bound upgrade apply, provider profile list/get, provider setup session and secret-ingress flows, provider validation lifecycle operations, provider credential lease issuance, and broker-owned session-turn-execution watch streams for in-flight execution state @@ -292,8 +301,8 @@ Alongside that still-incremental surface, the repository already includes workin - broker-projected secrets and model-gateway readiness surfaces plus model-gateway runtime enforcement for allowlisted destinations, canonical request binding, quota admission/stream checks, and audit-backed egress decisions - a trusted launcher service with `serve`, `--once`, Linux-first `--hello-world` operator paths, and a Linux-only explicit-opt-in container backend posture for offline `workspace` launches - signed runtime-image and runtime-toolchain admission into a launcher-private verified cache, plus typed verifier-authority import and fail-closed launch from admitted local assets -- launcher-produced runtime evidence persisted durably and projected into broker `RunSummary` / `RunDetail` authoritative state, including authoritative runtime lifecycle and attestation-support or verification detail derived from persisted evidence rather than client-local inference -- broker-emitted runtime launch/session audit events referencing persisted launcher evidence rather than transient launcher-local state +- launcher-produced runtime evidence persisted durably and projected into broker `RunSummary` / `RunDetail` authoritative state, including authoritative runtime lifecycle and attestation-support or verification detail derived from persisted evidence rather than client-local inference, with supported `attested` posture only projected after secure-session validation, post-handshake evidence collection, and trusted verification succeed +- broker-emitted runtime launch/session audit events referencing persisted launcher evidence rather than transient launcher-local state, while preserving attestation linkage from persisted post-handshake evidence instead of pre-persistence launch-time assumptions You can inspect their help output: diff --git a/cmd/runecode-broker/main_cli_core_test.go b/cmd/runecode-broker/main_cli_core_test.go index 0d15f494..ad3024d1 100644 --- a/cmd/runecode-broker/main_cli_core_test.go +++ b/cmd/runecode-broker/main_cli_core_test.go @@ -31,6 +31,7 @@ func TestHelpAndUnknownCommand(t *testing.T) { "--audit-ledger-root path", "--runtime-dir dir", "--socket-name name", + "low-level start default: change_draft", "audit-anchor-segment", "audit-record-inclusion-get", "audit-evidence-snapshot-get", diff --git a/cmd/runecode-broker/main_cli_local_api_adoption_test.go b/cmd/runecode-broker/main_cli_local_api_adoption_test.go index e080ad31..a7dadca0 100644 --- a/cmd/runecode-broker/main_cli_local_api_adoption_test.go +++ b/cmd/runecode-broker/main_cli_local_api_adoption_test.go @@ -234,6 +234,18 @@ func handleSessionRPCStub(t *testing.T, wire localRPCRequest) (localRPCResponse, case "session_send_message": return mustOKLocalRPCResponse(t, brokerapi.SessionSendMessageResponse{SchemaID: "runecode.protocol.v0.SessionSendMessageResponse", SchemaVersion: "0.1.0", RequestID: "req-session-send", SessionID: "sess-1", Turn: brokerapi.SessionTranscriptTurn{SchemaID: "runecode.protocol.v0.SessionTranscriptTurn", SchemaVersion: "0.1.0", TurnID: "sess-1.turn.000001", SessionID: "sess-1", TurnIndex: 1, StartedAt: "2026-01-01T00:00:00Z", CompletedAt: "2026-01-01T00:00:00Z", Status: "completed", Messages: []brokerapi.SessionTranscriptMessage{{SchemaID: "runecode.protocol.v0.SessionTranscriptMessage", SchemaVersion: "0.1.0", MessageID: "sess-1.turn.000001.msg.000001", TurnID: "sess-1.turn.000001", SessionID: "sess-1", MessageIndex: 1, Role: "user", CreatedAt: "2026-01-01T00:00:00Z", ContentText: "hello", RelatedLinks: brokerapi.SessionTranscriptLinks{SchemaID: "runecode.protocol.v0.SessionTranscriptLinks", SchemaVersion: "0.1.0", RunIDs: []string{}, ApprovalIDs: []string{}, ArtifactDigests: []string{}, AuditRecordDigests: []string{}}}}}, Message: brokerapi.SessionTranscriptMessage{SchemaID: "runecode.protocol.v0.SessionTranscriptMessage", SchemaVersion: "0.1.0", MessageID: "sess-1.turn.000001.msg.000001", TurnID: "sess-1.turn.000001", SessionID: "sess-1", MessageIndex: 1, Role: "user", CreatedAt: "2026-01-01T00:00:00Z", ContentText: "hello", RelatedLinks: brokerapi.SessionTranscriptLinks{SchemaID: "runecode.protocol.v0.SessionTranscriptLinks", SchemaVersion: "0.1.0", RunIDs: []string{}, ApprovalIDs: []string{}, ArtifactDigests: []string{}, AuditRecordDigests: []string{}}}, EventType: "session_message_ack", StreamID: "session-sess-1", Seq: 1}), true case "session_execution_trigger": + request := brokerapi.SessionExecutionTriggerRequest{} + if err := json.Unmarshal(wire.Request, &request); err != nil { + t.Fatalf("Unmarshal session_execution_trigger request error: %v", err) + } + if request.RequestedOperation == "start" { + if request.WorkflowRouting == nil { + t.Fatal("session_execution_trigger request missing workflow_routing for start") + } + if request.WorkflowRouting.WorkflowFamily != "runecontext" || request.WorkflowRouting.WorkflowOperation != "change_draft" { + t.Fatalf("session_execution_trigger default workflow_routing = %+v, want runecontext/change_draft", request.WorkflowRouting) + } + } return mustOKLocalRPCResponse(t, brokerapi.SessionExecutionTriggerResponse{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerResponse", SchemaVersion: "0.1.0", RequestID: "req-session-trigger", SessionID: "sess-1", TriggerID: "sess-1.trigger.000001", TriggerSource: "interactive_user", RequestedOperation: "start", UserMessageContentText: "hello", EventType: "session_execution_trigger_ack", StreamID: "session-sess-1", Seq: 1}), true case "session_watch": return mustOKLocalRPCResponse(t, []brokerapi.SessionWatchEvent{{SchemaID: "runecode.protocol.v0.SessionWatchEvent", SchemaVersion: "0.1.0", StreamID: "sw-1", RequestID: "req-session-watch", Seq: 1, EventType: "session_watch_snapshot", Session: &brokerapi.SessionSummary{SchemaID: "runecode.protocol.v0.SessionSummary", SchemaVersion: "0.1.0", Identity: brokerapi.SessionIdentity{SchemaID: "runecode.protocol.v0.SessionIdentity", SchemaVersion: "0.1.0", SessionID: "sess-1", WorkspaceID: "workspace-local", CreatedAt: "2026-01-01T00:00:00Z"}, UpdatedAt: "2026-01-01T00:00:00Z", Status: "active", LastActivityKind: "chat_message", TurnCount: 1, LinkedRunCount: 1, LinkedApprovalCount: 0, LinkedArtifactCount: 0, LinkedAuditEventCount: 0, HasIncompleteTurn: false}}, {SchemaID: "runecode.protocol.v0.SessionWatchEvent", SchemaVersion: "0.1.0", StreamID: "sw-1", RequestID: "req-session-watch", Seq: 2, EventType: "session_watch_terminal", Terminal: true, TerminalStatus: "completed"}}), true diff --git a/cmd/runecode-broker/main_cli_local_api_run_session_args_test.go b/cmd/runecode-broker/main_cli_local_api_run_session_args_test.go new file mode 100644 index 00000000..59d9c1c1 --- /dev/null +++ b/cmd/runecode-broker/main_cli_local_api_run_session_args_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "bytes" + "context" + "testing" + + "github.com/runecode-ai/runecode/internal/brokerapi" +) + +func TestRunAndSessionCommandsRejectPositionalArguments(t *testing.T) { + setBrokerServiceForTest(t) + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + originalDispatch := localRPCDispatch + localRPCDispatch = func(_ *brokerapi.Service, _ context.Context, wire localRPCRequest, _ brokerapi.RequestContext) localRPCResponse { + t.Fatalf("unexpected local rpc dispatch for %s", wire.Operation) + return localRPCResponse{} + } + t.Cleanup(func() { localRPCDispatch = originalDispatch }) + + for _, tt := range positionalArgRejectionCases() { + t.Run(tt.name, func(t *testing.T) { + stdout.Reset() + stderr.Reset() + err := run(tt.args, stdout, stderr) + if err == nil { + t.Fatalf("%s expected usage error for positional arguments", tt.name) + } + usageErr, ok := err.(*usageError) + if !ok { + t.Fatalf("%s error type = %T, want *usageError", tt.name, err) + } + if usageErr.Error() != tt.wantErr { + t.Fatalf("%s error = %q, want %q", tt.name, usageErr.Error(), tt.wantErr) + } + }) + } +} + +type positionalArgRejectionCase struct { + name string + args []string + wantErr string +} + +func positionalArgRejectionCases() []positionalArgRejectionCase { + return []positionalArgRejectionCase{ + {name: "run-list", args: []string{"run-list", "--limit", "1", "extra"}, wantErr: "run-list does not accept positional arguments"}, + {name: "run-get", args: []string{"run-get", "--run-id", "run-1", "extra"}, wantErr: "run-get does not accept positional arguments"}, + {name: "run-watch", args: []string{"run-watch", "--follow", "extra"}, wantErr: "run-watch does not accept positional arguments"}, + {name: "session-list", args: []string{"session-list", "--limit", "1", "extra"}, wantErr: "session-list does not accept positional arguments"}, + {name: "session-get", args: []string{"session-get", "--session-id", "sess-1", "extra"}, wantErr: "session-get does not accept positional arguments"}, + {name: "session-send-message", args: []string{"session-send-message", "--session-id", "sess-1", "--content", "hello", "extra"}, wantErr: "session-send-message does not accept positional arguments"}, + {name: "session-execution-trigger", args: []string{"session-execution-trigger", "--session-id", "sess-1", "--trigger-source", "interactive_user", "--requested-operation", "start", "--user-message", "hello", "extra"}, wantErr: "session-execution-trigger does not accept positional arguments"}, + {name: "session-watch", args: []string{"session-watch", "--follow", "extra"}, wantErr: "session-watch does not accept positional arguments"}, + } +} diff --git a/cmd/runecode-broker/main_help.go b/cmd/runecode-broker/main_help.go index f64ad567..7f947e79 100644 --- a/cmd/runecode-broker/main_help.go +++ b/cmd/runecode-broker/main_help.go @@ -18,7 +18,7 @@ Commands: session-list [--limit N] session-get --session-id id session-send-message --session-id id --content text [--role user|assistant|system|tool] [--idempotency-key key] - session-execution-trigger --session-id id [--turn-id id] [--trigger-source interactive_user|autonomous_background|resume_follow_up] [--requested-operation start|continue] [--workflow-family runecontext] [--workflow-operation change_draft|spec_draft|draft_promote_apply|approved_change_implementation] [--user-message text] [--idempotency-key key] + session-execution-trigger --session-id id [--turn-id id] [--trigger-source interactive_user|autonomous_background|resume_follow_up] [--requested-operation start|continue] [--workflow-family runecontext] [--workflow-operation change_draft|spec_draft|draft_promote_apply|approved_change_implementation] [--user-message text] [--idempotency-key key] (low-level start default: change_draft) session-watch [--stream-id id] [--session-id id] [--workspace-id id] [--status active|completed|archived] [--last-activity-kind kind] [--follow] [--include-snapshot] approval-list [--run-id id] [--status pending|approved|denied|expired|cancelled|superseded|consumed] [--limit N] approval-get --approval-id sha256:... diff --git a/cmd/runecode-broker/main_local_api_audit_cmds_test.go b/cmd/runecode-broker/main_local_api_audit_cmds_test.go index be72bd27..dacc35f3 100644 --- a/cmd/runecode-broker/main_local_api_audit_cmds_test.go +++ b/cmd/runecode-broker/main_local_api_audit_cmds_test.go @@ -1,6 +1,11 @@ package main import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" "testing" "github.com/runecode-ai/runecode/internal/brokerapi" @@ -22,3 +27,84 @@ func TestAuditAnchorFailureReasonFallsBackToFailureMessage(t *testing.T) { t.Fatalf("auditAnchorFailureReason() = %q, want external anchor confirmation is deferred", got) } } + +func TestAuditEvidenceBundleCommandsSmokePath(t *testing.T) { + root := setBrokerServiceForTest(t) + if err := seedLedgerForBrokerCommandTest(filepath.Join(root, "audit-ledger")); err != nil { + t.Fatalf("seedLedgerForBrokerCommandTest returned error: %v", err) + } + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + if err := run([]string{"audit-evidence-snapshot-get"}, stdout, stderr); err != nil { + t.Fatalf("audit-evidence-snapshot-get returned error: %v", err) + } + snapshot := brokerapi.AuditEvidenceSnapshot{} + if err := json.Unmarshal(stdout.Bytes(), &snapshot); err != nil { + t.Fatalf("audit-evidence-snapshot-get output parse error: %v", err) + } + if len(snapshot.SegmentSealDigests) == 0 { + t.Fatal("snapshot.segment_seal_digests empty, want evidence snapshot material") + } + + stdout.Reset() + requestPath, outPath := writeAuditEvidenceBundleExportFixtures(t) + if err := run([]string{"audit-evidence-bundle-export", "--request-file", requestPath, "--out", outPath}, stdout, stderr); err != nil { + t.Fatalf("audit-evidence-bundle-export returned error: %v", err) + } + exportResp := map[string]any{} + if err := json.Unmarshal(stdout.Bytes(), &exportResp); err != nil { + t.Fatalf("audit-evidence-bundle-export output parse error: %v", err) + } + if got := auditEvidenceBundleExportOutPath(t, exportResp); got != outPath { + t.Fatalf("export out path = %q, want %q", got, outPath) + } + if info, err := os.Stat(outPath); err != nil { + t.Fatalf("Stat(export out) returned error: %v", err) + } else if info.Size() == 0 { + t.Fatal("exported bundle size = 0, want tar archive bytes") + } + + stdout.Reset() + if err := run([]string{"audit-evidence-bundle-offline-verify", "--bundle", outPath, "--archive-format", "tar"}, stdout, stderr); err != nil { + t.Fatalf("audit-evidence-bundle-offline-verify returned error: %v", err) + } + verification := brokerapi.AuditEvidenceBundleOfflineVerification{} + if err := json.Unmarshal(stdout.Bytes(), &verification); err != nil { + t.Fatalf("audit-evidence-bundle-offline-verify output parse error: %v", err) + } + if verification.BundleID == "" || verification.VerificationStatus == "" { + t.Fatalf("offline verification missing core fields: %+v", verification) + } + if len(verification.VerificationReports) == 0 { + t.Fatal("offline verification reports empty, want projected report posture") + } +} + +func writeAuditEvidenceBundleExportFixtures(t *testing.T) (string, string) { + t.Helper() + tempRoot := canonicalTempDir(t) + requestPath := filepath.Join(tempRoot, "audit-evidence-bundle-export.request.json") + outPath := filepath.Join(tempRoot, "audit-evidence-bundle-export.tar") + writeJSONFixtureFile(t, requestPath, map[string]any{ + "scope": map[string]any{"scope_kind": "run", "run_id": "run-1"}, + "export_profile": "external_relying_party_minimal", + "created_by_tool": map[string]any{"tool_name": "runecode-broker", "tool_version": "0.0.0-dev"}, + "disclosure_posture": map[string]any{"posture": "digest_metadata_only", "selective_disclosure_applied": true}, + "archive_format": "tar", + }) + return requestPath, outPath +} + +func auditEvidenceBundleExportOutPath(t *testing.T, exportResp map[string]any) string { + t.Helper() + outValue, ok := exportResp["out"] + if !ok { + t.Fatalf("audit-evidence-bundle-export response missing out field: %#v", exportResp) + } + outString, ok := outValue.(string) + if !ok { + t.Fatalf("audit-evidence-bundle-export response out field has type %T, want string", outValue) + } + return strings.TrimSpace(outString) +} diff --git a/cmd/runecode-broker/main_local_api_run_session_cmds.go b/cmd/runecode-broker/main_local_api_run_session_cmds.go index 30dd58b0..05e43c7f 100644 --- a/cmd/runecode-broker/main_local_api_run_session_cmds.go +++ b/cmd/runecode-broker/main_local_api_run_session_cmds.go @@ -15,6 +15,9 @@ func handleRunList(args []string, service *brokerapi.Service, stdout io.Writer) if err := fs.Parse(args); err != nil { return &usageError{message: "run-list usage: runecode-broker run-list [--limit N]"} } + if err := rejectPositionalArgs("run-list", fs); err != nil { + return err + } api := localAPIForService(service) ctx, cancel := commandRequestContext(context.Background()) defer cancel() @@ -37,6 +40,9 @@ func handleRunGet(args []string, service *brokerapi.Service, stdout io.Writer) e if err := fs.Parse(args); err != nil { return &usageError{message: "run-get usage: runecode-broker run-get --run-id id"} } + if err := rejectPositionalArgs("run-get", fs); err != nil { + return err + } if *runID == "" { return &usageError{message: "run-get requires --run-id"} } @@ -67,6 +73,9 @@ func handleRunWatch(args []string, service *brokerapi.Service, stdout io.Writer) if err := fs.Parse(args); err != nil { return &usageError{message: "run-watch usage: runecode-broker run-watch [--stream-id id] [--run-id id] [--workspace-id id] [--lifecycle-state state] [--follow] [--include-snapshot]"} } + if err := rejectPositionalArgs("run-watch", fs); err != nil { + return err + } api := localAPIForService(service) ctx, cancel := commandRequestContext(context.Background()) defer cancel() @@ -99,6 +108,9 @@ func handleSessionList(args []string, service *brokerapi.Service, stdout io.Writ if err := fs.Parse(args); err != nil { return &usageError{message: "session-list usage: runecode-broker session-list [--limit N]"} } + if err := rejectPositionalArgs("session-list", fs); err != nil { + return err + } api := localAPIForService(service) ctx, cancel := commandRequestContext(context.Background()) defer cancel() @@ -121,6 +133,9 @@ func handleSessionGet(args []string, service *brokerapi.Service, stdout io.Write if err := fs.Parse(args); err != nil { return &usageError{message: "session-get usage: runecode-broker session-get --session-id id"} } + if err := rejectPositionalArgs("session-get", fs); err != nil { + return err + } if *sessionID == "" { return &usageError{message: "session-get requires --session-id"} } @@ -149,6 +164,9 @@ func handleSessionSendMessage(args []string, service *brokerapi.Service, stdout if err := fs.Parse(args); err != nil { return &usageError{message: "session-send-message usage: runecode-broker session-send-message --session-id id --content text [--role user|assistant|system|tool] [--idempotency-key key]"} } + if err := rejectPositionalArgs("session-send-message", fs); err != nil { + return err + } if *sessionID == "" { return &usageError{message: "session-send-message requires --session-id"} } @@ -184,11 +202,14 @@ func handleSessionExecutionTrigger(args []string, service *brokerapi.Service, st triggerSource := fs.String("trigger-source", "interactive_user", "trigger source classification") requestedOperation := fs.String("requested-operation", "start", "requested execution operation") workflowFamily := fs.String("workflow-family", "runecontext", "workflow pack family") - workflowOperation := fs.String("workflow-operation", "draft_promote_apply", "workflow pack operation") + workflowOperation := fs.String("workflow-operation", "change_draft", "workflow pack operation for start requests") userMessage := fs.String("user-message", "", "optional user message content") idempotencyKey := fs.String("idempotency-key", "", "optional idempotency key") if err := fs.Parse(args); err != nil { - return &usageError{message: "session-execution-trigger usage: runecode-broker session-execution-trigger --session-id id [--turn-id id] [--trigger-source interactive_user|autonomous_background|resume_follow_up] [--requested-operation start|continue] [--workflow-family runecontext] [--workflow-operation change_draft|spec_draft|draft_promote_apply|approved_change_implementation] [--user-message text] [--idempotency-key key]"} + return &usageError{message: "session-execution-trigger usage: runecode-broker session-execution-trigger --session-id id [--turn-id id] [--trigger-source interactive_user|autonomous_background|resume_follow_up] [--requested-operation start|continue] [--workflow-family runecontext] [--workflow-operation change_draft|spec_draft|draft_promote_apply|approved_change_implementation] [--user-message text] [--idempotency-key key] (start defaults to change_draft)"} + } + if err := rejectPositionalArgs("session-execution-trigger", fs); err != nil { + return err } if *sessionID == "" { return &usageError{message: "session-execution-trigger requires --session-id"} @@ -243,6 +264,9 @@ func handleSessionWatch(args []string, service *brokerapi.Service, stdout io.Wri if err := fs.Parse(args); err != nil { return &usageError{message: "session-watch usage: runecode-broker session-watch [--stream-id id] [--session-id id] [--workspace-id id] [--status active|completed|archived] [--last-activity-kind kind] [--follow] [--include-snapshot]"} } + if err := rejectPositionalArgs("session-watch", fs); err != nil { + return err + } api := localAPIForService(service) ctx, cancel := commandRequestContext(context.Background()) defer cancel() @@ -269,6 +293,13 @@ func handleSessionWatch(args []string, service *brokerapi.Service, stdout io.Wri return writeJSON(stdout, events) } +func rejectPositionalArgs(command string, fs *flag.FlagSet) error { + if len(fs.Args()) == 0 { + return nil + } + return &usageError{message: command + " does not accept positional arguments"} +} + func validSessionMessageRole(role string) bool { switch role { case "user", "assistant", "system", "tool": diff --git a/cmd/runecode-tui/local_rpc_integration_linux_test.go b/cmd/runecode-tui/local_rpc_integration_linux_test.go index 6e483898..475e63bf 100644 --- a/cmd/runecode-tui/local_rpc_integration_linux_test.go +++ b/cmd/runecode-tui/local_rpc_integration_linux_test.go @@ -37,10 +37,7 @@ func TestTUIRoutesUseRealLocalRPCBrokerContracts(t *testing.T) { func startTUILocalRPCServer(t *testing.T) (*brokerapi.LocalIPCListener, *brokerapi.Service, string, <-chan error) { t.Helper() - runtimeDir := filepath.Join(t.TempDir(), "runtime") - if err := os.MkdirAll(runtimeDir, 0o700); err != nil { - t.Fatalf("MkdirAll returned error: %v", err) - } + runtimeDir := shortTUILocalRPCRuntimeDir(t) service, ledgerRoot := newTUILocalRPCService(t) listener, err := brokerapi.ListenLocalIPC(brokerapi.LocalIPCConfig{RuntimeDir: runtimeDir, SocketName: "broker.sock"}) if err != nil { @@ -54,6 +51,16 @@ func startTUILocalRPCServer(t *testing.T) (*brokerapi.LocalIPCListener, *brokera return listener, service, ledgerRoot, errCh } +func shortTUILocalRPCRuntimeDir(t *testing.T) string { + t.Helper() + runtimeDir, err := os.MkdirTemp("", "rc-tui-rpc-") + if err != nil { + t.Fatalf("MkdirTemp returned error: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(runtimeDir) }) + return runtimeDir +} + func configureTUILocalRPCClient(t *testing.T, runtimeDir string) { t.Helper() origConfigProvider := localIPCConfigProvider diff --git a/cmd/runecode-tui/route_chat_state.go b/cmd/runecode-tui/route_chat_state.go index 1aa55c66..9531eff4 100644 --- a/cmd/runecode-tui/route_chat_state.go +++ b/cmd/runecode-tui/route_chat_state.go @@ -324,7 +324,7 @@ func defaultSessionWorkflowRouting() *brokerapi.SessionWorkflowPackRouting { SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", - WorkflowOperation: "draft_promote_apply", + WorkflowOperation: "change_draft", } } diff --git a/cmd/runecode-tui/route_chat_test.go b/cmd/runecode-tui/route_chat_test.go index b1beaaee..f6383cc5 100644 --- a/cmd/runecode-tui/route_chat_test.go +++ b/cmd/runecode-tui/route_chat_test.go @@ -162,7 +162,7 @@ func TestChatRouteComposeSendsTypedSessionMessageRequest(t *testing.T) { if spy.sentReq.UserMessageContentText != "hi" { t.Fatalf("expected content hi, got %q", spy.sentReq.UserMessageContentText) } - if spy.sentReq.WorkflowRouting == nil || spy.sentReq.WorkflowRouting.WorkflowFamily != "runecontext" || spy.sentReq.WorkflowRouting.WorkflowOperation != "draft_promote_apply" { + if spy.sentReq.WorkflowRouting == nil || spy.sentReq.WorkflowRouting.WorkflowFamily != "runecontext" || spy.sentReq.WorkflowRouting.WorkflowOperation != "change_draft" { t.Fatalf("unexpected workflow routing: %+v", spy.sentReq.WorkflowRouting) } if spy.watchReq == nil { diff --git a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go index ec8abbef..e942a350 100644 --- a/cmd/runecode-tui/route_runs_approvals_artifacts_test.go +++ b/cmd/runecode-tui/route_runs_approvals_artifacts_test.go @@ -24,10 +24,12 @@ func TestRunsRouteExplainsBrokerPostureAndStateTaxonomy(t *testing.T) { "Local actions: jump:approvals | jump:artifacts | jump:audit | copy:run_id", "Copy actions: run id | raw block", "backend_kind=workspace", + "Workflow identity (authoritative): workflow_kind=n/a workflow_definition_hash=n/a current_stage_", "Runtime isolation assurance (authoritative): runtime isolation=sandboxed", "Provisioning/binding posture (authoritative): provisioning posture=attested", "PROVISIONING_OK", "Attestation posture (authoritative): attestation posture=valid", + "Runtime attestation truthfulness (authoritative): post-handshake verification succeeded; support", "Verifier class (authoritative): verifier class=trusted_domain_local", "Supported runtime requirements (authoritative): supported_runtime_requirements_satisfied=true", "Reduced-assurance posture (authoritative): reduced_assurance=false", @@ -39,8 +41,6 @@ func TestRunsRouteExplainsBrokerPostureAndStateTaxonomy(t *testing.T) { "Coordination summary: blocked=true wait_reason=approval_wait", "Blocking cue:", "APPROVAL_REQUIRED", - "Stage summaries: 2 total, 1 with pending approvals", - "Role summaries: 2 total, 1 reporting coordination waits", ) if strings.Contains(view, "Summary: run=run-1 lifecycle=n/a pending_approvals=0") { t.Fatalf("expected run detail only in inspector region, got %q", view) diff --git a/cmd/runecode-tui/route_runs_detail.go b/cmd/runecode-tui/route_runs_detail.go index b9d59a4e..22202def 100644 --- a/cmd/runecode-tui/route_runs_detail.go +++ b/cmd/runecode-tui/route_runs_detail.go @@ -100,9 +100,11 @@ func runInspectorContent(summary brokerapi.RunSummary, detail *brokerapi.RunDeta attestationPosture, attestationReasons := attestationPostureFromState(detail.AuthoritativeState) return compactLines( fmt.Sprintf("backend_kind=%s", summary.BackendKind), + fmt.Sprintf("Workflow identity (authoritative): workflow_kind=%s workflow_definition_hash=%s current_stage_id=%s", valueOrNA(summary.WorkflowKind), valueOrNA(summary.WorkflowDefinitionHash), valueOrNA(summary.CurrentStageID)), "Runtime isolation assurance (authoritative): "+renderRuntimeIsolationCue(summary.BackendKind, summary.IsolationAssuranceLevel), "Provisioning/binding posture (authoritative): "+renderProvisioningPostureCue(summary.ProvisioningPosture), "Attestation posture (authoritative): "+renderAttestationPostureCue(attestationPosture, attestationReasons), + fmt.Sprintf("Runtime attestation truthfulness (authoritative): %s", renderRuntimeAttestationTruthfulnessCue(detail.AuthoritativeState)), "Verifier class (authoritative): "+renderAuthoritativeVerifierClassCue(detail.AuthoritativeState), "Supported runtime requirements (authoritative): "+renderSupportedRuntimeRequirementsCue(detail.AuthoritativeState), "Reduced-assurance posture (authoritative): "+renderReducedAssurancePostureCue(detail.AuthoritativeState), @@ -117,6 +119,32 @@ func runInspectorContent(summary brokerapi.RunSummary, detail *brokerapi.RunDeta ) } +func renderRuntimeAttestationTruthfulnessCue(state map[string]any) string { + attestationPosture, reasons := attestationPostureFromState(state) + verificationSucceeded, _ := state["attestation_verification_succeeded"].(bool) + sessionBindingPresent, _ := state["session_binding_present"].(bool) + attestationEvidencePresent, _ := state["attestation_evidence_present"].(bool) + supportedRuntimeSatisfied, _ := state["supported_runtime_requirements_satisfied"].(bool) + + currentEvidence := "launch-only evidence" + switch { + case verificationSucceeded: + currentEvidence = "post-handshake verification succeeded" + case attestationEvidencePresent: + currentEvidence = "post-handshake evidence collected but not yet supportable" + case sessionBindingPresent: + currentEvidence = "secure session bound without verified attestation" + } + + if supportedRuntimeSatisfied && attestationPosture == "valid" { + return currentEvidence + "; supported attested posture earned from verified post-handshake evidence" + } + if len(reasons) > 0 { + return currentEvidence + "; beta attested story still gated by post-handshake verification; reasons=" + strings.Join(reasons, ",") + } + return currentEvidence + "; beta attested story still gated by post-handshake verification" +} + func attestationPostureFromState(state map[string]any) (string, []string) { posture, _ := state["attestation_posture"].(string) reasonsAny, ok := state["attestation_reason_codes"].([]any) diff --git a/cmd/runecode-tui/route_status_test.go b/cmd/runecode-tui/route_status_test.go index a491fb48..c29eb139 100644 --- a/cmd/runecode-tui/route_status_test.go +++ b/cmd/runecode-tui/route_status_test.go @@ -173,3 +173,62 @@ func TestStatusRouteRendersDiagnosticsOnlyAttachGuidanceWhenNormalOperationBlock "Attach guidance: diagnostics/remediation-only attach is available; normal operation is blocked by current project-substrate posture.", ) } + +type blockedProjectSubstrateStatusClient struct { + *fakeBrokerClient +} + +func (f *blockedProjectSubstrateStatusClient) ProductLifecyclePostureGet(ctx context.Context) (brokerapi.ProductLifecyclePostureGetResponse, error) { + _, _ = f.fakeBrokerClient.ProductLifecyclePostureGet(ctx) + return brokerapi.ProductLifecyclePostureGetResponse{ProductLifecycle: brokerapi.BrokerProductLifecyclePosture{ + SchemaID: "runecode.protocol.v0.BrokerProductLifecyclePosture", + SchemaVersion: "0.1.0", + ProductInstanceID: "repo-test", + LifecycleGeneration: "gen-blocked-substrate", + AttachMode: "diagnostics_only", + LifecyclePosture: "blocked", + Attachable: true, + NormalOperationAllowed: false, + BlockedReasonCodes: []string{"project_substrate_missing"}, + }}, nil +} + +func (f *blockedProjectSubstrateStatusClient) ProjectSubstratePostureGet(ctx context.Context) (brokerapi.ProjectSubstratePostureGetResponse, error) { + _, _ = f.fakeBrokerClient.ProjectSubstratePostureGet(ctx) + return brokerapi.ProjectSubstratePostureGetResponse{ + SchemaID: "runecode.protocol.v0.ProjectSubstratePostureGetResponse", + SchemaVersion: "0.1.0", + RequestID: "req-project-substrate-posture-blocked", + RepositoryRoot: "/repo", + PostureSummary: brokerapi.ProjectSubstratePostureSummary{ + SchemaID: "runecode.protocol.v0.ProjectSubstratePostureSummary", + SchemaVersion: "0.1.0", + ValidationState: "missing", + CompatibilityPosture: "missing", + NormalOperationAllowed: false, + BlockedReasonCodes: []string{"project_substrate_missing"}, + }, + BlockedExplanation: "normal operation blocked by project substrate posture: project_substrate_missing", + RemediationGuidance: []string{"inspect_project_substrate_posture", "initialize_canonical_runecontext_substrate", "revalidate_project_substrate"}, + InitPreview: brokerapi.ProjectSubstrateInitPreviewResponse{Preview: brokerapi.ProjectSubstrateInitPreviewResponse{}.Preview}.Preview, + UpgradePreview: brokerapi.ProjectSubstrateUpgradePreviewResponse{}.Preview, + }, nil +} + +func TestStatusRouteRendersBlockedProjectSubstrateGuidance(t *testing.T) { + model := newStatusRouteModel(routeDefinition{ID: routeStatus, Label: "Status"}, &blockedProjectSubstrateStatusClient{fakeBrokerClient: &fakeBrokerClient{}}) + updated, cmd := model.Update(routeActivatedMsg{RouteID: routeStatus}) + if cmd == nil { + t.Fatal("expected activation load command") + } + updated, _ = updated.Update(cmd()) + view := updated.View(120, 40, focusContent) + mustContainAll(t, view, + "Project substrate posture:", + "state=missing", + "compatibility=missing", + "normal_operation_allowed=false", + "Project substrate block: normal operation blocked by project substrate posture: project_substrate_missing", + "Project substrate remediation: inspect_project_substrate_posture,initialize_canonical_runecontext_substrate,revalidate_project_substrate", + ) +} diff --git a/cmd/runecode-tui/route_tests_helpers_test.go b/cmd/runecode-tui/route_tests_helpers_test.go index 3a78d4e8..d9616ebb 100644 --- a/cmd/runecode-tui/route_tests_helpers_test.go +++ b/cmd/runecode-tui/route_tests_helpers_test.go @@ -219,7 +219,17 @@ func (f *reloadAwareBrokerClient) RunGet(ctx context.Context, runID string) (bro summary = brokerapi.RunSummary{RunID: runID, BackendKind: "container", IsolationAssuranceLevel: "reduced", ProvisioningPosture: "attested", AuditIntegrityStatus: "degraded", AuditAnchoringStatus: "degraded"} coordination = brokerapi.RunCoordinationSummary{Blocked: false, WaitReasonCode: "", CoordinationMode: "free"} } - return brokerapi.RunGetResponse{Run: brokerapi.RunDetail{Summary: summary, Coordination: coordination}}, nil + detail := brokerapi.RunDetail{Summary: summary, Coordination: coordination} + if runID == "run-2" { + detail.AuthoritativeState = map[string]any{ + "attestation_posture": "unavailable", + "session_binding_present": true, + "attestation_evidence_present": false, + "attestation_verification_succeeded": false, + "supported_runtime_requirements_satisfied": false, + } + } + return brokerapi.RunGetResponse{Run: detail}, nil } func (f *reloadAwareBrokerClient) RunWatch(ctx context.Context, req brokerapi.RunWatchRequest) ([]brokerapi.RunWatchEvent, error) { diff --git a/cmd/runecode-tui/shell_watch_test.go b/cmd/runecode-tui/shell_watch_test.go index 429ebdcd..860b3b3c 100644 --- a/cmd/runecode-tui/shell_watch_test.go +++ b/cmd/runecode-tui/shell_watch_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "errors" "testing" "time" @@ -8,6 +9,52 @@ import ( "github.com/runecode-ai/runecode/internal/brokerapi" ) +type watchPollRequestRecorder struct { + *fakeBrokerClient + run brokerapi.RunWatchRequest + approval brokerapi.ApprovalWatchRequest + session brokerapi.SessionWatchRequest +} + +func (r *watchPollRequestRecorder) RunWatch(ctx context.Context, req brokerapi.RunWatchRequest) ([]brokerapi.RunWatchEvent, error) { + r.run = req + return r.fakeBrokerClient.RunWatch(ctx, req) +} + +func (r *watchPollRequestRecorder) ApprovalWatch(ctx context.Context, req brokerapi.ApprovalWatchRequest) ([]brokerapi.ApprovalWatchEvent, error) { + r.approval = req + return r.fakeBrokerClient.ApprovalWatch(ctx, req) +} + +func (r *watchPollRequestRecorder) SessionWatch(ctx context.Context, req brokerapi.SessionWatchRequest) ([]brokerapi.SessionWatchEvent, error) { + r.session = req + return r.fakeBrokerClient.SessionWatch(ctx, req) +} + +func TestShellWatchPollRequestsSnapshotOnlyStreams(t *testing.T) { + m := newShellModel() + recorder := &watchPollRequestRecorder{fakeBrokerClient: &fakeBrokerClient{}} + m.client = recorder + + msg, ok := m.loadWatchPollCmd()().(shellWatchTransportLoadedMsg) + if !ok { + t.Fatalf("loadWatchPollCmd message = %T, want shellWatchTransportLoadedMsg", msg) + } + assertWatchPollSnapshotOnly(t, recorder.run.IncludeSnapshot, recorder.run.Follow, "run") + assertWatchPollSnapshotOnly(t, recorder.approval.IncludeSnapshot, recorder.approval.Follow, "approval") + assertWatchPollSnapshotOnly(t, recorder.session.IncludeSnapshot, recorder.session.Follow, "session") +} + +func assertWatchPollSnapshotOnly(t *testing.T, includeSnapshot, follow bool, family string) { + t.Helper() + if !includeSnapshot { + t.Fatalf("%s watch poll IncludeSnapshot = false, want true", family) + } + if follow { + t.Fatalf("%s watch poll Follow = true, want false", family) + } +} + func TestShellWatchManagerFamilySpecificFailureProjectsDegradedHealth(t *testing.T) { manager := newShellWatchManager() now := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC) diff --git a/cmd/runecode-tui/shell_watch_transport.go b/cmd/runecode-tui/shell_watch_transport.go index ee052d3b..251b5852 100644 --- a/cmd/runecode-tui/shell_watch_transport.go +++ b/cmd/runecode-tui/shell_watch_transport.go @@ -67,9 +67,9 @@ func (m shellModel) loadWatchPollCmd() tea.Cmd { ctx, cancel := withLoadTimeout() defer cancel() - runEvents, runErr := m.client.RunWatch(ctx, brokerapi.RunWatchRequest{StreamID: newRequestID("shell-run-watch-stream"), IncludeSnapshot: true, Follow: true}) - approvalEvents, approvalErr := m.client.ApprovalWatch(ctx, brokerapi.ApprovalWatchRequest{StreamID: newRequestID("shell-approval-watch-stream"), IncludeSnapshot: true, Follow: true}) - sessionEvents, sessionErr := m.client.SessionWatch(ctx, brokerapi.SessionWatchRequest{StreamID: newRequestID("shell-session-watch-stream"), IncludeSnapshot: true, Follow: true}) + runEvents, runErr := m.client.RunWatch(ctx, brokerapi.RunWatchRequest{StreamID: newRequestID("shell-run-watch-stream"), IncludeSnapshot: true}) + approvalEvents, approvalErr := m.client.ApprovalWatch(ctx, brokerapi.ApprovalWatchRequest{StreamID: newRequestID("shell-approval-watch-stream"), IncludeSnapshot: true}) + sessionEvents, sessionErr := m.client.SessionWatch(ctx, brokerapi.SessionWatchRequest{StreamID: newRequestID("shell-session-watch-stream"), IncludeSnapshot: true}) return shellWatchTransportLoadedMsg{ Run: shellWatchRunTransportResult{Events: runEvents, Err: runErr}, diff --git a/formal/tla/security-kernel/README.md b/formal/tla/security-kernel/README.md index 56ad8763..717c810d 100644 --- a/formal/tla/security-kernel/README.md +++ b/formal/tla/security-kernel/README.md @@ -51,3 +51,9 @@ TLC wiring is owned by the CI/tooling lane. When TLC tooling is available, run w - configs: - `formal/tla/security-kernel/SecurityKernelV0.core.cfg` - `formal/tla/security-kernel/SecurityKernelV0.replay.cfg` + +Convenience recipes: + +- `just model-check-core` runs the faster core PR gate. +- `just model-check-replay` runs the broader replay model. +- `just model-check` runs both and is included in full local `just ci` parity. diff --git a/go.mod b/go.mod index 8cecb1ab..af2e4983 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/creack/pty v1.1.24 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index 9b1aa713..b8cf1fca 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payR github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= diff --git a/internal/artifacts/backup_manifest.go b/internal/artifacts/backup_manifest.go index 0b3d32d1..4efcf81c 100644 --- a/internal/artifacts/backup_manifest.go +++ b/internal/artifacts/backup_manifest.go @@ -188,6 +188,7 @@ func cloneExternalAnchorPreparedRecord(in ExternalAnchorPreparedMutationRecord) func cloneRuntimeFactsSnapshot(in launcherbackend.RuntimeFactsSnapshot) launcherbackend.RuntimeFactsSnapshot { out := in out.LaunchReceipt = in.LaunchReceipt.Normalized() + out.PostHandshakeAttestationInput = launcherbackend.NormalizePostHandshakeRuntimeAttestationInput(in.PostHandshakeAttestationInput) out.HardeningPosture = in.HardeningPosture.Normalized() out.TerminalReport = normalizeRuntimeTerminalReport(in.TerminalReport) return out diff --git a/internal/artifacts/store_runtime_attestation_cache.go b/internal/artifacts/store_runtime_attestation_cache.go index 63d3a28a..4b32f8f5 100644 --- a/internal/artifacts/store_runtime_attestation_cache.go +++ b/internal/artifacts/store_runtime_attestation_cache.go @@ -52,7 +52,7 @@ func shouldApplyCachedAttestationVerification(verification *launcherbackend.Isol if verification == nil { return true } - return strings.TrimSpace(verification.AttestationEvidenceDigest) == "" && + if strings.TrimSpace(verification.AttestationEvidenceDigest) == "" && strings.TrimSpace(verification.ReplayIdentityDigest) == "" && strings.TrimSpace(verification.VerifierPolicyDigest) == "" && strings.TrimSpace(verification.VerifierPolicyID) == "" && @@ -62,7 +62,31 @@ func shouldApplyCachedAttestationVerification(verification *launcherbackend.Isol strings.TrimSpace(verification.ReplayVerdict) == "" && strings.TrimSpace(verification.VerificationDigest) == "" && len(verification.ReasonCodes) == 0 && - len(verification.DerivedMeasurementDigests) == 0 + len(verification.DerivedMeasurementDigests) == 0 { + return true + } + return isAttestationVerificationReplayPlaceholder(*verification) +} + +func isAttestationVerificationReplayPlaceholder(verification launcherbackend.IsolateAttestationVerificationRecord) bool { + if strings.TrimSpace(verification.VerificationResult) != launcherbackend.AttestationVerificationResultInvalid { + return false + } + if strings.TrimSpace(verification.ReplayVerdict) != launcherbackend.AttestationReplayVerdictUnknown { + return false + } + if len(verification.ReasonCodes) == 0 { + return false + } + for _, reason := range verification.ReasonCodes { + switch strings.TrimSpace(reason) { + case "attestation_verification_not_valid", "attestation_verification_required", "attestation_verification_unavailable": + continue + default: + return false + } + } + return true } func attestationVerificationEvidenceDigest(evidence launcherbackend.RuntimeEvidenceSnapshot) string { diff --git a/internal/artifacts/store_runtime_facts.go b/internal/artifacts/store_runtime_facts.go index 741e21bd..a79e0f73 100644 --- a/internal/artifacts/store_runtime_facts.go +++ b/internal/artifacts/store_runtime_facts.go @@ -15,9 +15,14 @@ func (s *Store) RecordRuntimeEvidenceState(runID string, facts launcherbackend.R return fmt.Errorf("run id is required") } facts.LaunchReceipt = facts.LaunchReceipt.Normalized() + facts.PostHandshakeAttestationInput = launcherbackend.NormalizePostHandshakeRuntimeAttestationInput(facts.PostHandshakeAttestationInput) facts.HardeningPosture = facts.HardeningPosture.Normalized() facts.TerminalReport = normalizeRuntimeTerminalReport(facts.TerminalReport) evidence = s.applyCachedAttestationVerificationLocked(evidence) + if err := launcherbackend.ReconcileRuntimeEvidenceAttestation(facts.LaunchReceipt, facts.PostHandshakeAttestationInput, &evidence); err != nil { + return err + } + reconcileAuthoritativeProvisioningPosture(&facts, &evidence) s.upsertAttestationVerificationCacheLocked(evidence) s.state.RuntimeFactsByRun[trimmedRunID] = facts s.state.RuntimeEvidenceByRun[trimmedRunID] = evidence @@ -45,6 +50,7 @@ func (s *Store) RuntimeEvidenceState(runID string) (launcherbackend.RuntimeFacts } evidence := s.state.RuntimeEvidenceByRun[trimmedRunID] evidence = s.applyCachedAttestationVerificationLocked(evidence) + reconcileAuthoritativeProvisioningPosture(&facts, &evidence) lifecycle := s.state.RuntimeLifecycleByRun[trimmedRunID] auditState := s.state.RuntimeAuditStateByRun[trimmedRunID] return facts, evidence, lifecycle, auditState, true @@ -81,25 +87,20 @@ func (s *Store) UpdateRuntimeLifecycleState(runID string, lifecycle launcherback if trimmedRunID == "" { return fmt.Errorf("run id is required") } - facts, ok := s.state.RuntimeFactsByRun[trimmedRunID] - if !ok { - facts = launcherbackend.DefaultRuntimeFacts(trimmedRunID) - } - if lifecycle.BackendLifecycle != nil { - normalized := lifecycle.BackendLifecycle.Normalized() - facts.LaunchReceipt.Lifecycle = &normalized - } - if strings.TrimSpace(lifecycle.ProvisioningPosture) != "" { - facts.LaunchReceipt.ProvisioningPosture = lifecycle.ProvisioningPosture - } - facts.LaunchReceipt.ProvisioningPostureDegraded = lifecycle.ProvisioningPostureDegraded - facts.LaunchReceipt.ProvisioningDegradedReasons = append([]string{}, lifecycle.ProvisioningDegradedReasons...) - facts.LaunchReceipt.LaunchFailureReasonCode = strings.TrimSpace(lifecycle.LaunchFailureReasonCode) + facts, existed := s.runtimeFactsForLifecycleUpdateLocked(trimmedRunID) + applyLifecycleToRuntimeFacts(&facts, &lifecycle) evidence, projectedLifecycle, err := launcherbackend.SplitRuntimeFactsEvidenceAndLifecycle(facts) if err != nil { + if !existed { + return s.persistLifecycleFallbackLocked(trimmedRunID, facts, lifecycle) + } return err } evidence = s.applyCachedAttestationVerificationLocked(evidence) + if err := launcherbackend.ReconcileRuntimeEvidenceAttestation(facts.LaunchReceipt, facts.PostHandshakeAttestationInput, &evidence); err != nil { + return err + } + reconcileAuthoritativeProvisioningPosture(&facts, &evidence) s.upsertAttestationVerificationCacheLocked(evidence) s.state.RuntimeFactsByRun[trimmedRunID] = facts s.state.RuntimeEvidenceByRun[trimmedRunID] = evidence @@ -111,6 +112,49 @@ func (s *Store) UpdateRuntimeLifecycleState(runID string, lifecycle launcherback return s.saveStateLocked() } +func (s *Store) runtimeFactsForLifecycleUpdateLocked(runID string) (launcherbackend.RuntimeFactsSnapshot, bool) { + facts, ok := s.state.RuntimeFactsByRun[runID] + if ok { + return facts, true + } + return launcherbackend.DefaultRuntimeFacts(runID), false +} + +func applyLifecycleToRuntimeFacts(facts *launcherbackend.RuntimeFactsSnapshot, lifecycle *launcherbackend.RuntimeLifecycleState) { + if facts == nil || lifecycle == nil { + return + } + if lifecycle.BackendLifecycle != nil { + normalized := lifecycle.BackendLifecycle.Normalized() + facts.LaunchReceipt.Lifecycle = &normalized + } + if normalized := normalizeLifecycleProvisioningPosture(lifecycle); normalized != "" { + facts.LaunchReceipt.ProvisioningPosture = normalized + } + facts.LaunchReceipt.ProvisioningPostureDegraded = lifecycle.ProvisioningPostureDegraded + facts.LaunchReceipt.ProvisioningDegradedReasons = append([]string{}, lifecycle.ProvisioningDegradedReasons...) + facts.LaunchReceipt.LaunchFailureReasonCode = strings.TrimSpace(lifecycle.LaunchFailureReasonCode) +} + +func normalizeLifecycleProvisioningPosture(lifecycle *launcherbackend.RuntimeLifecycleState) string { + if lifecycle == nil { + return "" + } + if strings.TrimSpace(lifecycle.ProvisioningPosture) == launcherbackend.ProvisioningPostureAttested { + lifecycle.ProvisioningPosture = "" + } + return lifecycle.ProvisioningPosture +} + +func (s *Store) persistLifecycleFallbackLocked(runID string, facts launcherbackend.RuntimeFactsSnapshot, lifecycle launcherbackend.RuntimeLifecycleState) error { + s.state.RuntimeFactsByRun[runID] = facts + s.state.RuntimeLifecycleByRun[runID] = lifecycle + if _, exists := s.state.Runs[runID]; !exists { + s.state.Runs[runID] = "active" + } + return s.saveStateLocked() +} + func normalizeRuntimeTerminalReport(report *launcherbackend.BackendTerminalReport) *launcherbackend.BackendTerminalReport { if report == nil { return nil @@ -118,3 +162,42 @@ func normalizeRuntimeTerminalReport(report *launcherbackend.BackendTerminalRepor normalized := report.Normalized() return &normalized } + +func reconcileAuthoritativeProvisioningPosture(facts *launcherbackend.RuntimeFactsSnapshot, evidence *launcherbackend.RuntimeEvidenceSnapshot) { + if evidence == nil { + return + } + posture := authoritativeProvisioningPostureFromEvidence(*evidence) + evidence.Launch.ProvisioningPosture = posture + if evidence.Session != nil { + evidence.Session.ProvisioningPosture = sessionProvisioningPostureForEvidence(*evidence, posture) + } + if facts != nil { + facts.LaunchReceipt.ProvisioningPosture = posture + } +} + +func authoritativeProvisioningPostureFromEvidence(evidence launcherbackend.RuntimeEvidenceSnapshot) string { + launchPosture := strings.TrimSpace(evidence.Launch.ProvisioningPosture) + attestationPosture, _ := launcherbackend.DeriveAttestationPostureFromEvidence(evidence) + if attestationPosture == launcherbackend.AttestationPostureValid { + return launcherbackend.ProvisioningPostureAttested + } + if launchPosture == launcherbackend.ProvisioningPostureAttested { + return launcherbackend.ProvisioningPostureTOFU + } + return launchPosture +} + +func sessionProvisioningPostureForEvidence(evidence launcherbackend.RuntimeEvidenceSnapshot, launchPosture string) string { + if evidence.Session == nil { + return "" + } + if strings.TrimSpace(evidence.Session.ProvisioningPosture) != launcherbackend.ProvisioningPostureAttested { + return evidence.Session.ProvisioningPosture + } + if launchPosture == launcherbackend.ProvisioningPostureAttested { + return launcherbackend.ProvisioningPostureAttested + } + return launcherbackend.ProvisioningPostureTOFU +} diff --git a/internal/artifacts/store_runtime_facts_test.go b/internal/artifacts/store_runtime_facts_test.go index d24952f9..ca2894d0 100644 --- a/internal/artifacts/store_runtime_facts_test.go +++ b/internal/artifacts/store_runtime_facts_test.go @@ -111,13 +111,22 @@ func TestRecordRuntimeEvidenceStateUsesCachedAttestationVerificationOnReplay(t * if replayedEvidence.AttestationVerification == nil { t.Fatal("expected fail-closed placeholder verification before cache application") } - if replayedEvidence.AttestationVerification.VerifierPolicyDigest != "" { - t.Fatal("expected replayed placeholder verification to have empty policy digest") + if replayedEvidence.AttestationVerification.VerifierPolicyDigest == "" { + t.Fatal("expected trusted verification defaults before cache application") } if err := store.RecordRuntimeEvidenceState(runID, replayedFacts, replayedEvidence, replayedLifecycle); err != nil { t.Fatalf("RecordRuntimeEvidenceState(replayed) returned error: %v", err) } - assertPersistedInvalidAttestationVerification(t, store, runID) + _, persistedEvidence, _, _, ok := store.RuntimeEvidenceState(runID) + if !ok { + t.Fatal("RuntimeEvidenceState = not found, want persisted runtime state") + } + if persistedEvidence.AttestationVerification == nil { + t.Fatal("expected persisted attestation verification") + } + if persistedEvidence.AttestationVerification.VerificationResult != launcherbackend.AttestationVerificationResultValid { + t.Fatalf("verification result = %q, want %q", persistedEvidence.AttestationVerification.VerificationResult, launcherbackend.AttestationVerificationResultValid) + } } func TestRecordRuntimeEvidenceStateInvalidatesAttestationVerificationCacheOnAuthorityChange(t *testing.T) { @@ -199,8 +208,8 @@ func TestRecordRuntimeEvidenceStateAttestationVerificationCacheDoesNotApplyWitho if persistedEvidence.AttestationVerification == nil { t.Fatal("expected fail-closed attestation verification to remain persisted") } - if persistedEvidence.AttestationVerification.VerifierPolicyDigest != "" { - t.Fatalf("expected cache miss without measurement profile, got verifier policy digest %q", persistedEvidence.AttestationVerification.VerifierPolicyDigest) + if persistedEvidence.AttestationVerification.VerificationResult != launcherbackend.AttestationVerificationResultValid { + t.Fatalf("verification result = %q, want %q", persistedEvidence.AttestationVerification.VerificationResult, launcherbackend.AttestationVerificationResultValid) } } @@ -231,8 +240,8 @@ func TestRecordRuntimeEvidenceStateDoesNotApplyCacheToPartialVerificationRecord( if persistedEvidence.AttestationVerification == nil { t.Fatal("expected persisted attestation verification") } - if persistedEvidence.AttestationVerification.VerifierPolicyDigest != "" { - t.Fatalf("expected cache not to overwrite partial verification record, got verifier policy digest %q", persistedEvidence.AttestationVerification.VerifierPolicyDigest) + if persistedEvidence.AttestationVerification.VerifierPolicyDigest == "" { + t.Fatal("expected partial verification record to retain verifier policy digest") } if persistedEvidence.AttestationVerification.ReplayIdentityDigest == "" { t.Fatal("expected partial verification replay identity to be preserved") @@ -276,10 +285,17 @@ func runtimeFactsWithValidAttestationVerification(t *testing.T, runID string, au t.Helper() facts := runtimeFactsFixtureForStoreRuntimeTests(t, runID) facts.LaunchReceipt.AuthorityStateDigest = DigestBytes([]byte(authoritySeed)) - facts.LaunchReceipt.AttestationVerifierPolicyID = "runtime_asset_admission_identity" + facts.LaunchReceipt.AttestationVerifierPolicyID = strings.TrimSpace(policySeed) facts.LaunchReceipt.AttestationVerifierPolicyDigest = DigestBytes([]byte(policySeed)) facts.LaunchReceipt.AttestationVerificationResult = launcherbackend.AttestationVerificationResultValid facts.LaunchReceipt.AttestationReplayVerdict = launcherbackend.AttestationReplayVerdictOriginal + facts.PostHandshakeAttestationInput = postHandshakeAttestationInputForStoreRuntimeFacts(facts.LaunchReceipt) + facts.PostHandshakeAttestationInput.AuthorityStateDigest = DigestBytes([]byte(authoritySeed)) + facts.PostHandshakeAttestationInput.VerifierPolicyID = strings.TrimSpace(policySeed) + facts.PostHandshakeAttestationInput.VerifierPolicyDigest = DigestBytes([]byte(policySeed)) + facts.PostHandshakeAttestationInput.VerificationResult = launcherbackend.AttestationVerificationResultValid + facts.PostHandshakeAttestationInput.ReplayVerdict = launcherbackend.AttestationReplayVerdictOriginal + facts.PostHandshakeAttestationInput.RuntimeEvidenceCollected = true return facts } @@ -292,6 +308,8 @@ func replayedRuntimeFactsWithoutVerifierIdentity(facts launcherbackend.RuntimeFa replayedFacts.LaunchReceipt.AttestationVerificationResult = "" replayedFacts.LaunchReceipt.AttestationVerificationReasonCodes = nil replayedFacts.LaunchReceipt.AttestationReplayVerdict = "" + replayedFacts.PostHandshakeAttestationInput = postHandshakeAttestationInputForStoreRuntimeFacts(replayedFacts.LaunchReceipt) + replayedFacts.PostHandshakeAttestationInput.RuntimeEvidenceCollected = true return replayedFacts } @@ -356,6 +374,15 @@ func runtimeFactsFixtureForStoreRuntimeTests(t *testing.T, runID string) launche facts.LaunchReceipt.LaunchContextDigest = testDigest("4") facts.LaunchReceipt.HandshakeTranscriptHash = testDigest("5") facts.LaunchReceipt.IsolateSessionKeyIDValue = strings.Repeat("f", 64) + facts.LaunchReceipt.SessionSecurity = &launcherbackend.SessionSecurityPosture{ + MutuallyAuthenticated: true, + Encrypted: true, + ProofOfPossessionVerified: true, + ReplayProtected: true, + FrameFormat: launcherbackend.SessionFramingLengthPrefixedV1, + MaxFrameBytes: 4096, + MaxHandshakeMessageBytes: 2048, + } facts.LaunchReceipt.RuntimeImageDescriptorDigest = testDigest("6") facts.LaunchReceipt.RuntimeImageBootProfile = launcherbackend.BootProfileMicroVMLinuxKernelInitrdV1 facts.LaunchReceipt.BootComponentDigestByName = map[string]string{"kernel": testDigest("7"), "initrd": testDigest("8")} @@ -369,9 +396,34 @@ func runtimeFactsFixtureForStoreRuntimeTests(t *testing.T, runID string) launche t.Fatalf("DeriveExpectedMeasurementDigests returned error: %v", err) } facts.LaunchReceipt.AttestationEvidenceClaimsDigest = digests[0] + facts.PostHandshakeAttestationInput = postHandshakeAttestationInputForStoreRuntimeFacts(facts.LaunchReceipt) return facts } +func postHandshakeAttestationInputForStoreRuntimeFacts(receipt launcherbackend.BackendLaunchReceipt) *launcherbackend.PostHandshakeRuntimeAttestationInput { + return &launcherbackend.PostHandshakeRuntimeAttestationInput{ + RunID: receipt.RunID, + IsolateID: receipt.IsolateID, + SessionID: receipt.SessionID, + SessionNonce: receipt.SessionNonce, + RuntimeEvidenceCollected: true, + LaunchContextDigest: receipt.LaunchContextDigest, + HandshakeTranscriptHash: receipt.HandshakeTranscriptHash, + IsolateSessionKeyIDValue: receipt.IsolateSessionKeyIDValue, + RuntimeImageDescriptorDigest: receipt.RuntimeImageDescriptorDigest, + RuntimeImageBootProfile: receipt.RuntimeImageBootProfile, + RuntimeImageVerifierRef: receipt.RuntimeImageVerifierRef, + AuthorityStateDigest: receipt.AuthorityStateDigest, + BootComponentDigestByName: map[string]string{"kernel": receipt.BootComponentDigestByName["kernel"], "initrd": receipt.BootComponentDigestByName["initrd"]}, + BootComponentDigests: append([]string{}, receipt.BootComponentDigests...), + AttestationSourceKind: receipt.AttestationEvidenceSourceKind, + MeasurementProfile: receipt.AttestationMeasurementProfile, + FreshnessMaterial: append([]string{}, receipt.AttestationFreshnessMaterial...), + FreshnessBindingClaims: append([]string{}, receipt.AttestationFreshnessBindingClaims...), + EvidenceClaimsDigest: receipt.AttestationEvidenceClaimsDigest, + } +} + func assertSessionStateBoundFromRuntimeFacts(t *testing.T, store *Store, sessionID, runID string) { t.Helper() session, ok := store.SessionState(sessionID) diff --git a/internal/brokerapi/api_constants.go b/internal/brokerapi/api_constants.go index 674a7033..da3b59cb 100644 --- a/internal/brokerapi/api_constants.go +++ b/internal/brokerapi/api_constants.go @@ -53,6 +53,7 @@ type APIConfig struct { Compile CompileConfig ExternalAnchor ExternalAnchorConfig RepositoryRoot string + RunnerNodePath string } type DependencyFetchConfig struct { diff --git a/internal/brokerapi/api_ops_test.go b/internal/brokerapi/api_ops_test.go index aa2073c6..8e11adcd 100644 --- a/internal/brokerapi/api_ops_test.go +++ b/internal/brokerapi/api_ops_test.go @@ -178,6 +178,7 @@ func newBrokerAPIServiceForTests(t *testing.T, cfg APIConfig) *Service { if err != nil { t.Fatalf("NewServiceWithConfig returned error: %v", err) } + service.sessionExecutionRunner = launchSessionExecutionRunnerInProcessForTests service.SetDependencyRegistryFetcherForTests(streamingFetcher{payload: "test-default-dependency-payload"}) return service } diff --git a/internal/brokerapi/broker_audit_helpers_test.go b/internal/brokerapi/broker_audit_helpers_test.go new file mode 100644 index 00000000..047e14b8 --- /dev/null +++ b/internal/brokerapi/broker_audit_helpers_test.go @@ -0,0 +1,136 @@ +package brokerapi + +import ( + "encoding/json" + "testing" + + "github.com/runecode-ai/runecode/internal/artifacts" +) + +func findLauncherRuntimeAuditEvent(t *testing.T, events []artifacts.AuditEvent, runtimeEventType string) artifacts.AuditEvent { + t.Helper() + for _, event := range events { + if event.Type != brokerAuditEventTypeLauncherRuntime { + continue + } + if event.Details["runtime_event_type"] != runtimeEventType { + continue + } + return event + } + t.Fatalf("missing %s launcher runtime audit event", runtimeEventType) + return artifacts.AuditEvent{} +} + +func launcherRuntimeAuditEventsByRuntimeType(events []artifacts.AuditEvent, runtimeEventType string) []artifacts.AuditEvent { + matched := make([]artifacts.AuditEvent, 0, 1) + for _, event := range events { + if event.Type != brokerAuditEventTypeLauncherRuntime { + continue + } + if event.Details["runtime_event_type"] != runtimeEventType { + continue + } + matched = append(matched, event) + } + return matched +} + +func launcherRuntimeAuditEventPayload(t *testing.T, event artifacts.AuditEvent) map[string]any { + t.Helper() + rawPayload, ok := event.Details["event_payload"] + if !ok { + t.Fatal("event_payload missing from launcher runtime audit details") + } + switch payload := rawPayload.(type) { + case map[string]any: + return payload + case json.RawMessage: + return decodeLauncherRuntimeAuditPayload(t, payload, "RawMessage") + case []byte: + return decodeLauncherRuntimeAuditPayload(t, payload, "bytes") + case string: + return decodeLauncherRuntimeAuditPayload(t, []byte(payload), "string") + default: + t.Fatalf("event_payload = %T, want object or JSON payload", rawPayload) + return nil + } +} + +func decodeLauncherRuntimeAuditPayload(t *testing.T, payload []byte, label string) map[string]any { + t.Helper() + var decoded map[string]any + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("json.Unmarshal(event_payload %s) returned error: %v", label, err) + } + return decoded +} + +func assertLauncherRuntimeAuditDigests(t *testing.T, event artifacts.AuditEvent, launchDigest, hardeningDigest, sessionDigest string) { + t.Helper() + digests := launcherRuntimeAuditDigests(t, event) + assertLauncherRuntimeAuditDigestValue(t, digests, "launch_receipt", launchDigest) + assertLauncherRuntimeAuditDigestValue(t, digests, "hardening_posture", hardeningDigest) + assertLauncherRuntimeAuditDigestValue(t, digests, "session_binding", sessionDigest) +} + +func launcherRuntimeAuditDigests(t *testing.T, event artifacts.AuditEvent) map[string]any { + t.Helper() + digests, ok := event.Details["stored_runtime_fact_digests"].(map[string]any) + if !ok { + t.Fatalf("stored_runtime_fact_digests = %T, want map", event.Details["stored_runtime_fact_digests"]) + } + return digests +} + +func assertLauncherRuntimeAuditDigestValue(t *testing.T, digests map[string]any, name, want string) { + t.Helper() + if digests[name] != want { + t.Fatalf("%s digest = %v, want %q", name, digests[name], want) + } +} + +func countLauncherRuntimeAuditEvents(events []artifacts.AuditEvent) int { + count := 0 + for _, event := range events { + if event.Type == brokerAuditEventTypeLauncherRuntime { + count++ + } + } + return count +} + +func countRuntimeLaunchDeniedEventsByReasonCode(t *testing.T, events []artifacts.AuditEvent, reasonCode string) int { + t.Helper() + count := 0 + for _, event := range events { + if event.Type != brokerAuditEventTypeLauncherRuntime { + continue + } + if event.Details["runtime_event_type"] != "runtime_launch_denied" { + continue + } + payload := launcherRuntimeAuditEventPayload(t, event) + if payload["launch_failure_reason_code"] == reasonCode { + count++ + } + } + return count +} + +func assertBrokerRejectionAuditEvent(t *testing.T, events []artifacts.AuditEvent, requestID, reasonCode string) { + t.Helper() + for _, event := range events { + if event.Type != brokerAuditEventTypeRejection { + continue + } + if event.Details["request_id"] != requestID { + continue + } + if event.Details["reason_code"] != reasonCode { + t.Fatalf("reason_code = %v, want %s", event.Details["reason_code"], reasonCode) + } + return + } + t.Fatalf("missing broker rejection audit event for request_id=%s reason_code=%s", requestID, reasonCode) +} diff --git a/internal/brokerapi/broker_audit_test.go b/internal/brokerapi/broker_audit_test.go index 92571a62..d04962a1 100644 --- a/internal/brokerapi/broker_audit_test.go +++ b/internal/brokerapi/broker_audit_test.go @@ -2,7 +2,6 @@ package brokerapi import ( "context" - "encoding/json" "strings" "testing" @@ -285,6 +284,118 @@ func TestRuntimeSessionAuditPayloadIncludesAttestationEvidenceDigestAdditively(t assertLauncherRuntimeAuditDigestValue(t, launcherRuntimeAuditDigests(t, startedEvent), "attestation_evidence", evidence.Attestation.EvidenceDigest) } +func TestRuntimeSessionBoundAuditTracksPersistedAttestationStateTransitions(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + const runID = "run-runtime-attestation-transition" + _ = putRunScopedArtifactForLocalOpsTest(t, s, runID, "step-1") + + recordRuntimeFactsForAuditTransition(t, s, runID, preAttestationRuntimeFacts(runID), "pre-attestation") + assertBoundAuditBeforePersistedAttestation(t, s) + + attestedFacts := attestedRuntimeFacts(runID) + recordRuntimeFactsForAuditTransition(t, s, runID, attestedFacts, "attested") + assertBoundAuditAfterPersistedAttestation(t, s, runID) + + recordRuntimeFactsForAuditTransition(t, s, runID, attestedFacts, "attested repeat") + assertBoundAuditEventCount(t, s, 2, "after repeat") +} + +func preAttestationRuntimeFacts(runID string) launcherbackend.RuntimeFactsSnapshot { + facts := launcherRuntimeFactsFixture() + facts.LaunchReceipt.RunID = runID + facts.LaunchReceipt.LaunchFailureReasonCode = "" + facts.LaunchReceipt.AttestationEvidenceSourceKind = launcherbackend.AttestationSourceKindUnknown + facts.LaunchReceipt.AttestationMeasurementProfile = "" + facts.LaunchReceipt.AttestationFreshnessMaterial = nil + facts.LaunchReceipt.AttestationFreshnessBindingClaims = nil + facts.LaunchReceipt.AttestationEvidenceClaimsDigest = "" + facts.LaunchReceipt.AttestationVerifierPolicyID = "" + facts.LaunchReceipt.AttestationVerifierPolicyDigest = "" + facts.LaunchReceipt.AttestationVerificationRulesVersion = "" + facts.LaunchReceipt.AttestationVerificationTimestamp = "" + facts.LaunchReceipt.AttestationVerificationResult = "" + facts.LaunchReceipt.AttestationVerificationReasonCodes = nil + facts.LaunchReceipt.AttestationReplayVerdict = "" + facts.PostHandshakeAttestationInput = nil + return facts +} + +func attestedRuntimeFacts(runID string) launcherbackend.RuntimeFactsSnapshot { + facts := launcherRuntimeFactsFixture() + facts.LaunchReceipt.RunID = runID + facts.LaunchReceipt.LaunchFailureReasonCode = "" + facts.LaunchReceipt.AttestationVerifierPolicyID = "runtime_asset_admission_identity" + facts.LaunchReceipt.AttestationVerifierPolicyDigest = facts.LaunchReceipt.AuthorityStateDigest + facts.LaunchReceipt.AttestationVerificationResult = launcherbackend.AttestationVerificationResultValid + facts.LaunchReceipt.AttestationReplayVerdict = launcherbackend.AttestationReplayVerdictOriginal + facts.PostHandshakeAttestationInput = runtimeFactsPostHandshakeAttestationInput(facts.LaunchReceipt) + facts.PostHandshakeAttestationInput.VerifierPolicyID = facts.LaunchReceipt.AttestationVerifierPolicyID + facts.PostHandshakeAttestationInput.VerifierPolicyDigest = facts.LaunchReceipt.AttestationVerifierPolicyDigest + facts.PostHandshakeAttestationInput.VerificationResult = facts.LaunchReceipt.AttestationVerificationResult + facts.PostHandshakeAttestationInput.ReplayVerdict = facts.LaunchReceipt.AttestationReplayVerdict + return facts +} + +func recordRuntimeFactsForAuditTransition(t *testing.T, s *Service, runID string, facts launcherbackend.RuntimeFactsSnapshot, label string) { + t.Helper() + if err := s.RecordRuntimeFacts(runID, facts); err != nil { + t.Fatalf("RecordRuntimeFacts(%s) returned error: %v", label, err) + } +} + +func assertBoundAuditBeforePersistedAttestation(t *testing.T, s *Service) { + t.Helper() + boundEvents := requireBoundAuditEvents(t, s, 1, "before persisted attestation evidence") + firstPayload := launcherRuntimeAuditEventPayload(t, boundEvents[0]) + if _, ok := firstPayload["attestation_evidence_digest"]; ok { + t.Fatalf("first isolate_session_bound payload attestation_evidence_digest = %v, want omitted before persisted attestation", firstPayload["attestation_evidence_digest"]) + } +} + +func assertBoundAuditAfterPersistedAttestation(t *testing.T, s *Service, runID string) { + t.Helper() + boundEvents := requireBoundAuditEvents(t, s, 2, "after attestation transition") + latestBound := boundEvents[len(boundEvents)-1] + latestPayload := launcherRuntimeAuditEventPayload(t, latestBound) + persistedEvidence := requirePersistedAttestationVerificationEvidence(t, s, runID) + if latestPayload["attestation_evidence_digest"] != persistedEvidence.Attestation.EvidenceDigest { + t.Fatalf("latest isolate_session_bound payload attestation_evidence_digest = %v, want %q", latestPayload["attestation_evidence_digest"], persistedEvidence.Attestation.EvidenceDigest) + } + if latestBound.Details["attestation_posture"] != launcherbackend.AttestationPostureValid { + t.Fatalf("latest isolate_session_bound details attestation_posture = %v, want %q", latestBound.Details["attestation_posture"], launcherbackend.AttestationPostureValid) + } +} + +func assertBoundAuditEventCount(t *testing.T, s *Service, want int, label string) { + t.Helper() + requireBoundAuditEvents(t, s, want, label) +} + +func requireBoundAuditEvents(t *testing.T, s *Service, want int, label string) []artifacts.AuditEvent { + t.Helper() + events, err := s.ReadAuditEvents() + if err != nil { + t.Fatalf("ReadAuditEvents(%s) returned error: %v", label, err) + } + boundEvents := launcherRuntimeAuditEventsByRuntimeType(events, "isolate_session_bound") + if len(boundEvents) != want { + t.Fatalf("isolate_session_bound event count %s = %d, want %d", label, len(boundEvents), want) + } + return boundEvents +} + +func requirePersistedAttestationVerificationEvidence(t *testing.T, s *Service, runID string) launcherbackend.RuntimeEvidenceSnapshot { + t.Helper() + _, persistedEvidence, _, _, ok := s.store.RuntimeEvidenceState(runID) + if !ok { + t.Fatal("RuntimeEvidenceState = not found, want persisted runtime evidence") + } + if persistedEvidence.Attestation == nil || persistedEvidence.AttestationVerification == nil { + t.Fatalf("persisted evidence missing attestation/verification after attested facts: %#v", persistedEvidence) + } + return persistedEvidence +} + func attestationAuditRuntimeFacts() launcherbackend.RuntimeFactsSnapshot { facts := launcherRuntimeFactsFixture() facts.LaunchReceipt.RunID = "run-runtime-attestation-audit" @@ -293,6 +404,15 @@ func attestationAuditRuntimeFacts() launcherbackend.RuntimeFactsSnapshot { facts.LaunchReceipt.AttestationFreshnessMaterial = []string{"quote_nonce"} facts.LaunchReceipt.AttestationFreshnessBindingClaims = []string{"session_nonce", "handshake_transcript_hash"} facts.LaunchReceipt.AttestationEvidenceClaimsDigest = runtimeFactsMeasurementDigests(facts.LaunchReceipt)[0] + facts.LaunchReceipt.AttestationVerifierPolicyID = "runtime_asset_admission_identity" + facts.LaunchReceipt.AttestationVerifierPolicyDigest = facts.LaunchReceipt.AuthorityStateDigest + facts.LaunchReceipt.AttestationVerificationResult = launcherbackend.AttestationVerificationResultValid + facts.LaunchReceipt.AttestationReplayVerdict = launcherbackend.AttestationReplayVerdictOriginal + facts.PostHandshakeAttestationInput = runtimeFactsPostHandshakeAttestationInput(facts.LaunchReceipt) + facts.PostHandshakeAttestationInput.VerifierPolicyID = facts.LaunchReceipt.AttestationVerifierPolicyID + facts.PostHandshakeAttestationInput.VerifierPolicyDigest = facts.LaunchReceipt.AttestationVerifierPolicyDigest + facts.PostHandshakeAttestationInput.VerificationResult = facts.LaunchReceipt.AttestationVerificationResult + facts.PostHandshakeAttestationInput.ReplayVerdict = facts.LaunchReceipt.AttestationReplayVerdict return facts } @@ -333,120 +453,3 @@ func assertLauncherRuntimeAuditEvent(t *testing.T, events []artifacts.AuditEvent event := findLauncherRuntimeAuditEvent(t, events, runtimeEventType) assertLauncherRuntimeAuditDigests(t, event, launchDigest, hardeningDigest, sessionDigest) } - -func findLauncherRuntimeAuditEvent(t *testing.T, events []artifacts.AuditEvent, runtimeEventType string) artifacts.AuditEvent { - t.Helper() - for _, event := range events { - if event.Type != brokerAuditEventTypeLauncherRuntime { - continue - } - if event.Details["runtime_event_type"] != runtimeEventType { - continue - } - return event - } - t.Fatalf("missing %s launcher runtime audit event", runtimeEventType) - return artifacts.AuditEvent{} -} - -func launcherRuntimeAuditEventPayload(t *testing.T, event artifacts.AuditEvent) map[string]any { - t.Helper() - rawPayload, ok := event.Details["event_payload"] - if !ok { - t.Fatal("event_payload missing from launcher runtime audit details") - } - switch payload := rawPayload.(type) { - case map[string]any: - return payload - case json.RawMessage: - var decoded map[string]any - if err := json.Unmarshal(payload, &decoded); err != nil { - t.Fatalf("json.Unmarshal(event_payload RawMessage) returned error: %v", err) - } - return decoded - case []byte: - var decoded map[string]any - if err := json.Unmarshal(payload, &decoded); err != nil { - t.Fatalf("json.Unmarshal(event_payload bytes) returned error: %v", err) - } - return decoded - case string: - var decoded map[string]any - if err := json.Unmarshal([]byte(payload), &decoded); err != nil { - t.Fatalf("json.Unmarshal(event_payload string) returned error: %v", err) - } - return decoded - default: - t.Fatalf("event_payload = %T, want object or JSON payload", rawPayload) - return nil - } -} - -func assertLauncherRuntimeAuditDigests(t *testing.T, event artifacts.AuditEvent, launchDigest, hardeningDigest, sessionDigest string) { - t.Helper() - digests := launcherRuntimeAuditDigests(t, event) - assertLauncherRuntimeAuditDigestValue(t, digests, "launch_receipt", launchDigest) - assertLauncherRuntimeAuditDigestValue(t, digests, "hardening_posture", hardeningDigest) - assertLauncherRuntimeAuditDigestValue(t, digests, "session_binding", sessionDigest) -} - -func launcherRuntimeAuditDigests(t *testing.T, event artifacts.AuditEvent) map[string]any { - t.Helper() - digests, ok := event.Details["stored_runtime_fact_digests"].(map[string]any) - if !ok { - t.Fatalf("stored_runtime_fact_digests = %T, want map", event.Details["stored_runtime_fact_digests"]) - } - return digests -} - -func assertLauncherRuntimeAuditDigestValue(t *testing.T, digests map[string]any, name, want string) { - t.Helper() - if digests[name] != want { - t.Fatalf("%s digest = %v, want %q", name, digests[name], want) - } -} - -func countLauncherRuntimeAuditEvents(events []artifacts.AuditEvent) int { - count := 0 - for _, event := range events { - if event.Type == brokerAuditEventTypeLauncherRuntime { - count++ - } - } - return count -} - -func countRuntimeLaunchDeniedEventsByReasonCode(t *testing.T, events []artifacts.AuditEvent, reasonCode string) int { - t.Helper() - count := 0 - for _, event := range events { - if event.Type != brokerAuditEventTypeLauncherRuntime { - continue - } - if event.Details["runtime_event_type"] != "runtime_launch_denied" { - continue - } - payload := launcherRuntimeAuditEventPayload(t, event) - if payload["launch_failure_reason_code"] == reasonCode { - count++ - } - } - return count -} - -func assertBrokerRejectionAuditEvent(t *testing.T, events []artifacts.AuditEvent, requestID, reasonCode string) { - t.Helper() - for _, event := range events { - if event.Type != brokerAuditEventTypeRejection { - continue - } - if event.Details["request_id"] != requestID { - continue - } - if event.Details["reason_code"] != reasonCode { - t.Fatalf("reason_code = %v, want %s", event.Details["reason_code"], reasonCode) - } - return - } - t.Fatalf("missing broker rejection audit event for request_id=%s reason_code=%s", requestID, reasonCode) -} diff --git a/internal/brokerapi/local_api_artifact_payload_verify.go b/internal/brokerapi/local_api_artifact_payload_verify.go new file mode 100644 index 00000000..d3c9adbe --- /dev/null +++ b/internal/brokerapi/local_api_artifact_payload_verify.go @@ -0,0 +1,19 @@ +package brokerapi + +import ( + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/artifacts" +) + +func (s *Service) readArtifactPayloadVerified(digest string) ([]byte, error) { + payload, err := s.readArtifactPayload(digest) + if err != nil { + return nil, err + } + if artifacts.DigestBytes(payload) != strings.TrimSpace(digest) { + return nil, fmt.Errorf("artifact payload digest drift for %q", strings.TrimSpace(digest)) + } + return payload, nil +} diff --git a/internal/brokerapi/local_api_broker_owned_mutation_commit.go b/internal/brokerapi/local_api_broker_owned_mutation_commit.go new file mode 100644 index 00000000..a66d45b7 --- /dev/null +++ b/internal/brokerapi/local_api_broker_owned_mutation_commit.go @@ -0,0 +1,193 @@ +package brokerapi + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/runecode-ai/runecode/internal/artifacts" +) + +type brokerOwnedMutationWriteIntent struct { + targetAbsolutePath string + targetRelativePath string + writeMode string + contents []byte + expectedDigest string + mode os.FileMode +} + +type brokerOwnedPreparedMutationWrite struct { + intent brokerOwnedMutationWriteIntent + snapshot brokerOwnedFileSnapshot +} + +var brokerOwnedMutationPostWriteHookForTest func(path string) error + +func prepareBrokerOwnedMutationWrites(intents []brokerOwnedMutationWriteIntent) ([]brokerOwnedPreparedMutationWrite, error) { + prepared := make([]brokerOwnedPreparedMutationWrite, 0, len(intents)) + seenTargets := map[string]struct{}{} + for _, intent := range intents { + normalized, err := normalizeBrokerOwnedMutationIntent(intent) + if err != nil { + return nil, err + } + trimmedTarget := normalized.targetAbsolutePath + if _, exists := seenTargets[trimmedTarget]; exists { + return nil, fmt.Errorf("broker-owned mutation target %q is duplicated", trimmedTarget) + } + seenTargets[trimmedTarget] = struct{}{} + if err := validateBrokerOwnedMutationWriteMode(normalized); err != nil { + return nil, err + } + snapshot, err := captureBrokerOwnedFileSnapshot(trimmedTarget) + if err != nil { + return nil, err + } + prepared = append(prepared, brokerOwnedPreparedMutationWrite{intent: normalized, snapshot: snapshot}) + } + return prepared, nil +} + +func normalizeBrokerOwnedMutationIntent(intent brokerOwnedMutationWriteIntent) (brokerOwnedMutationWriteIntent, error) { + trimmedTarget := filepath.Clean(strings.TrimSpace(intent.targetAbsolutePath)) + if trimmedTarget == "" { + return brokerOwnedMutationWriteIntent{}, fmt.Errorf("broker-owned mutation target path is required") + } + if strings.TrimSpace(intent.expectedDigest) == "" { + return brokerOwnedMutationWriteIntent{}, fmt.Errorf("broker-owned mutation expected digest is required for %q", trimmedTarget) + } + if intent.mode == 0 { + intent.mode = 0o644 + } + intent.targetAbsolutePath = trimmedTarget + intent.contents = append([]byte(nil), intent.contents...) + return intent, nil +} + +func validateBrokerOwnedMutationWriteMode(intent brokerOwnedMutationWriteIntent) error { + if strings.TrimSpace(intent.writeMode) == "" { + return nil + } + return validateApprovedImplementationWriteMode(intent.targetAbsolutePath, intent.writeMode) +} + +func finalizeBrokerOwnedMutationWrites(prepared []brokerOwnedPreparedMutationWrite, finalize func() error) error { + if err := writePreparedBrokerOwnedMutationWrites(prepared); err != nil { + return err + } + if finalize == nil { + return nil + } + if err := finalize(); err != nil { + return joinBrokerOwnedRollbackError(err, rollbackPreparedBrokerOwnedMutationWrites(prepared)) + } + return nil +} + +func writePreparedBrokerOwnedMutationWrites(prepared []brokerOwnedPreparedMutationWrite) error { + for _, write := range prepared { + if err := writeBrokerOwnedMutationFile(write.intent); err != nil { + return joinBrokerOwnedRollbackError(err, rollbackPreparedBrokerOwnedMutationWrites(prepared)) + } + } + return nil +} + +func rollbackPreparedBrokerOwnedMutationWrites(prepared []brokerOwnedPreparedMutationWrite) error { + snapshots := make([]brokerOwnedFileSnapshot, 0, len(prepared)) + for _, write := range prepared { + snapshots = append(snapshots, write.snapshot) + } + return rollbackBrokerOwnedFileSnapshots(snapshots) +} + +func writeBrokerOwnedMutationFile(intent brokerOwnedMutationWriteIntent) error { + if err := writeBrokerOwnedMutationFileUnverified(intent); err != nil { + return err + } + if brokerOwnedMutationPostWriteHookForTest != nil { + if err := brokerOwnedMutationPostWriteHookForTest(intent.targetAbsolutePath); err != nil { + return fmt.Errorf("broker-owned mutation post-write hook: %w", err) + } + } + if err := verifyBrokerOwnedMutationWrite(intent); err != nil { + return err + } + return nil +} + +func writeBrokerOwnedMutationFileUnverified(intent brokerOwnedMutationWriteIntent) error { + switch strings.TrimSpace(intent.writeMode) { + case "": + return writeBrokerOwnedDraftPromoteFile(intent.targetAbsolutePath, intent.contents, intent.mode) + case "create": + return writeBrokerOwnedCreateFile(intent.targetAbsolutePath, intent.contents, intent.mode) + case "update": + return writeBrokerOwnedDraftPromoteFile(intent.targetAbsolutePath, intent.contents, intent.mode) + default: + return fmt.Errorf("broker-owned mutation write_mode %q is unsupported", strings.TrimSpace(intent.writeMode)) + } +} + +func verifyBrokerOwnedMutationWrite(intent brokerOwnedMutationWriteIntent) error { + payload, err := os.ReadFile(intent.targetAbsolutePath) + if err != nil { + return fmt.Errorf("read broker-owned mutation target after write: %w", err) + } + if got := artifacts.DigestBytes(payload); got != strings.TrimSpace(intent.expectedDigest) { + path := strings.TrimSpace(intent.targetRelativePath) + if path == "" { + path = strings.TrimSpace(intent.targetAbsolutePath) + } + return fmt.Errorf("broker-owned mutation post-write digest drift for %q", path) + } + return nil +} + +func writeBrokerOwnedCreateFile(path string, contents []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create approved implementation target parent: %w", err) + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return fmt.Errorf("create approved implementation target: %w", err) + } + if err := file.Chmod(mode); err != nil { + _ = file.Close() + _ = os.Remove(path) + return fmt.Errorf("chmod approved implementation target: %w", err) + } + if _, err := file.Write(contents); err != nil { + _ = file.Close() + _ = os.Remove(path) + return fmt.Errorf("write approved implementation target: %w", err) + } + if err := file.Close(); err != nil { + _ = os.Remove(path) + return fmt.Errorf("close approved implementation target: %w", err) + } + return nil +} + +func validateApprovedImplementationWriteMode(targetPath, writeMode string) error { + switch strings.TrimSpace(writeMode) { + case "create": + if _, err := os.Stat(targetPath); err == nil { + return fmt.Errorf("approved implementation create target already exists: %s", targetPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("stat approved implementation create target: %w", err) + } + case "update": + if _, err := os.Stat(targetPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("approved implementation update target does not exist: %s", targetPath) + } + return fmt.Errorf("stat approved implementation update target: %w", err) + } + default: + return fmt.Errorf("approved implementation write_mode %q is unsupported", strings.TrimSpace(writeMode)) + } + return nil +} diff --git a/internal/brokerapi/local_api_broker_owned_write_rollback.go b/internal/brokerapi/local_api_broker_owned_write_rollback.go new file mode 100644 index 00000000..4b0519eb --- /dev/null +++ b/internal/brokerapi/local_api_broker_owned_write_rollback.go @@ -0,0 +1,59 @@ +package brokerapi + +import ( + "errors" + "fmt" + "os" +) + +type brokerOwnedFileSnapshot struct { + path string + existed bool + contents []byte + mode os.FileMode +} + +func captureBrokerOwnedFileSnapshot(path string) (brokerOwnedFileSnapshot, error) { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return brokerOwnedFileSnapshot{path: path}, nil + } + return brokerOwnedFileSnapshot{}, fmt.Errorf("stat broker-owned write target: %w", err) + } + contents, err := os.ReadFile(path) + if err != nil { + return brokerOwnedFileSnapshot{}, fmt.Errorf("read broker-owned write target snapshot: %w", err) + } + return brokerOwnedFileSnapshot{path: path, existed: true, contents: contents, mode: info.Mode()}, nil +} + +func rollbackBrokerOwnedFileSnapshots(snapshots []brokerOwnedFileSnapshot) error { + var joined error + for i := len(snapshots) - 1; i >= 0; i-- { + if err := rollbackBrokerOwnedFileSnapshot(snapshots[i]); err != nil { + joined = errors.Join(joined, err) + } + } + return joined +} + +func rollbackBrokerOwnedFileSnapshot(snapshot brokerOwnedFileSnapshot) error { + if !snapshot.existed { + if err := os.Remove(snapshot.path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove broker-owned write target during rollback: %w", err) + } + return nil + } + if err := writeBrokerOwnedDraftPromoteFile(snapshot.path, snapshot.contents, snapshot.mode); err != nil { + return fmt.Errorf("restore broker-owned write target during rollback: %w", err) + } + return nil +} + +func joinBrokerOwnedRollbackError(cause error, rollbackErr error) error { + if rollbackErr == nil { + return cause + } + return errors.Join(cause, fmt.Errorf("broker-owned write rollback failed: %w", rollbackErr)) +} diff --git a/internal/brokerapi/local_api_dependency_cache_flow_test.go b/internal/brokerapi/local_api_dependency_cache_flow_test.go index 568bf010..99db01f7 100644 --- a/internal/brokerapi/local_api_dependency_cache_flow_test.go +++ b/internal/brokerapi/local_api_dependency_cache_flow_test.go @@ -2,9 +2,13 @@ package brokerapi import ( "context" + "crypto/sha256" + "encoding/hex" + "strings" "testing" "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/trustpolicy" ) func TestDependencyCacheEnsureHitAndMiss(t *testing.T) { @@ -150,6 +154,63 @@ func TestDependencyCacheHandoffOperationNotFoundAndValidationDenied(t *testing.T } } +func TestDependencyCacheHandoffRequestIDsRemainStableAndDistinctForLongRunIDDigestPairs(t *testing.T) { + s, runIDA, runIDB, digestA, digestB := newDependencyCacheHandoffRequestIDFixture(t) + respA1 := mustHandleDependencyCacheHandoff(t, s, dependencyCacheHandoffRequestWithDigest(runIDA, digestA)) + respB := mustHandleDependencyCacheHandoff(t, s, dependencyCacheHandoffRequestWithDigest(runIDA, digestB)) + respA2 := mustHandleDependencyCacheHandoff(t, s, dependencyCacheHandoffRequestWithDigest(runIDA, digestA)) + respAOtherRun := mustHandleDependencyCacheHandoff(t, s, dependencyCacheHandoffRequestWithDigest(runIDB, digestA)) + + if respA1.RequestID != respA2.RequestID { + t.Fatalf("stable request_id mismatch: %q vs %q", respA1.RequestID, respA2.RequestID) + } + if respA1.RequestID == respB.RequestID { + t.Fatal("request_id collision for distinct request_digest values") + } + if respA1.RequestID == respAOtherRun.RequestID { + t.Fatal("request_id collision for distinct run_id values") + } +} + +func TestDependencyCacheHandoffRequestIDUsesFullHashLength(t *testing.T) { + _, runIDA, _, digestA, _ := newDependencyCacheHandoffRequestIDFixture(t) + requestID := dependencyCacheHandoffRequestWithDigest(runIDA, digestA).RequestID + if got, want := len(requestID), len("dependency-handoff:")+64; got != want { + t.Fatalf("request_id length = %d, want %d", got, want) + } +} + +func newDependencyCacheHandoffRequestIDFixture(t *testing.T) (*Service, string, string, trustpolicy.Digest, trustpolicy.Digest) { + t.Helper() + s := newBrokerAPIServiceForTests(t, APIConfig{}) + putTrustedDependencyFetchContextForRun(t, s, "run-deps") + seedDependencyCacheForHandoff(t, s, "req-handoff-long", "run-deps", "handoff-long") + return s, + "run-" + strings.Repeat("shared-prefix-", 12) + "A", + "run-" + strings.Repeat("shared-prefix-", 12) + "B", + mustDigestObjectFromIdentity("sha256:" + strings.Repeat("a", 64)), + mustDigestObjectFromIdentity("sha256:" + strings.Repeat("b", 64)) +} + +func dependencyCacheHandoffRequestWithDigest(runID string, digest trustpolicy.Digest) DependencyCacheHandoffRequest { + return DependencyCacheHandoffRequest{ + SchemaID: "runecode.protocol.v0.DependencyCacheHandoffRequest", + SchemaVersion: "0.1.0", + RequestID: requestIDForLongRunnerPair(runID, digest), + RequestDigest: digest, + ConsumerRole: "workspace", + } +} + +func requestIDForLongRunnerPair(runID string, digest trustpolicy.Digest) string { + identity, err := digest.Identity() + if err != nil { + panic(err) + } + sum := sha256.Sum256([]byte(runID + "\n" + identity)) + return "dependency-handoff:" + hex.EncodeToString(sum[:]) +} + func seedDependencyCacheForHandoff(t *testing.T, s *Service, requestID, runID, pkg string) { t.Helper() _, errResp := s.HandleDependencyCacheEnsure(context.Background(), dependencyCacheEnsureRequestForTest(requestID, runID, pkg), RequestContext{}) diff --git a/internal/brokerapi/local_api_llm_execution.go b/internal/brokerapi/local_api_llm_execution.go index 96f665e4..4042eb33 100644 --- a/internal/brokerapi/local_api_llm_execution.go +++ b/internal/brokerapi/local_api_llm_execution.go @@ -139,9 +139,10 @@ func (s *Service) executeProviderRequest(ctx context.Context, execCtx llmExecuti } req, errResp := s.buildProviderHTTPRequest(ctx, execCtx, body, started) if errResp != nil { - return "", int64(len(body)), started, time.Now().UTC(), errResp + return "", int64(len(body)), started, normalizeExecutionCompletedAt(started, time.Now().UTC()), errResp } respBody, completed, errResp := s.doProviderHTTPRequest(execCtx, req) + completed = normalizeExecutionCompletedAt(started, completed) if errResp != nil { return "", int64(len(body)), started, completed, errResp } @@ -152,6 +153,13 @@ func (s *Service) executeProviderRequest(ctx context.Context, execCtx llmExecuti return text, int64(len(body)), started, completed, nil } +func normalizeExecutionCompletedAt(started, completed time.Time) time.Time { + if completed.After(started) { + return completed + } + return started.Add(time.Millisecond) +} + func (s *Service) prepareProviderRequestBody(requestID string, translated map[string]any) ([]byte, time.Time, *ErrorResponse) { body, err := json.Marshal(translated) if err != nil { diff --git a/internal/brokerapi/local_api_ops_approval_shared_support_test.go b/internal/brokerapi/local_api_ops_approval_shared_support_test.go index 9f5fa8f0..18fe4963 100644 --- a/internal/brokerapi/local_api_ops_approval_shared_support_test.go +++ b/internal/brokerapi/local_api_ops_approval_shared_support_test.go @@ -28,6 +28,7 @@ func setupServiceWithApprovalFixtureAndOutcome(t *testing.T, outcome string) (*S if err != nil { t.Fatalf("NewServiceWithConfig returned error: %v", err) } + s.sessionExecutionRunner = launchSessionExecutionRunnerCheckpointOnlyInProcessForTests unapproved, err := s.Put(artifacts.PutRequest{Payload: []byte("private excerpt"), ContentType: "text/plain", DataClass: artifacts.DataClassUnapprovedFileExcerpts, ProvenanceReceiptHash: "sha256:" + strings.Repeat("b", 64), CreatedByRole: "workspace", RunID: "run-approval", StepID: "step-1"}) if err != nil { t.Fatalf("Put unapproved returned error: %v", err) diff --git a/internal/brokerapi/local_api_ops_audit_evidence_bundle_export_test.go b/internal/brokerapi/local_api_ops_audit_evidence_bundle_export_test.go index d29772fd..070dfc98 100644 --- a/internal/brokerapi/local_api_ops_audit_evidence_bundle_export_test.go +++ b/internal/brokerapi/local_api_ops_audit_evidence_bundle_export_test.go @@ -209,6 +209,123 @@ func TestAuditEvidenceBundleOfflineVerifySurfacesDegradedPostureFromBundle(t *te } } +func TestAuditEvidenceBundleExportAndOfflineVerifySmokeForWorkflowRun(t *testing.T) { + service := newWorkflowRunBundleSmokeService(t) + archiveBytes := exportWorkflowRunBundleForSmoke(t, service) + entries := readAuditBundleTarEntries(t, archiveBytes) + if _, ok := entries["manifest.json"]; !ok { + t.Fatal("manifest.json missing from workflow-run export") + } + dir := canonicalTempDir(t) + bundlePath := filepath.Join(dir, "workflow-run-smoke-bundle.tar") + if err := os.WriteFile(bundlePath, archiveBytes, 0o600); err != nil { + t.Fatalf("WriteFile(bundlePath) returned error: %v", err) + } + + verifyResp, errResp := service.HandleAuditEvidenceBundleOfflineVerify(context.Background(), AuditEvidenceBundleOfflineVerifyRequest{ + SchemaID: "runecode.protocol.v0.AuditEvidenceBundleOfflineVerifyRequest", + SchemaVersion: "0.1.0", + RequestID: "req-audit-bundle-smoke-offline-verify", + BundlePath: bundlePath, + ArchiveFormat: "tar", + }, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleAuditEvidenceBundleOfflineVerify returned error: %+v", errResp) + } + if got := verifyResp.Verification.Scope.RunID; got != "run-1" { + t.Fatalf("offline verification scope.run_id = %q, want run-1", got) + } + if verifyResp.Verification.ManifestDigest == nil { + t.Fatal("offline verification manifest_digest = nil, want preserved manifest identity") + } + if len(verifyResp.Verification.VerificationReports) == 0 { + t.Fatal("offline verification reports empty for workflow-run export") + } +} + +func newWorkflowRunBundleSmokeService(t *testing.T) *Service { + t.Helper() + repoRoot := t.TempDir() + writeProjectSubstrateAnchors(t, repoRoot, "0.1.0-alpha.14", "verified", "runecontext") + storeRoot := t.TempDir() + ledgerRoot := t.TempDir() + if err := seedLedgerForBrokerSurfaceTest(ledgerRoot); err != nil { + t.Fatalf("seedLedgerForBrokerSurfaceTest returned error: %v", err) + } + service, err := NewServiceWithConfig(storeRoot, ledgerRoot, APIConfig{RepositoryRoot: repoRoot}) + if err != nil { + t.Fatalf("NewServiceWithConfig returned error: %v", err) + } + service.sessionExecutionRunner = launchSessionExecutionRunnerCompleteInProcessForTests + seedSessionRuntimeFactsForOpsTest(t, service, "run-audit-bundle-smoke", "sess-audit-bundle-smoke") + return service +} + +func exportWorkflowRunBundleForSmoke(t *testing.T, service *Service) []byte { + t.Helper() + changeDigest := runWorkflowRunBundleSmokeDraftAndPromote(t, service) + events, errResp := service.HandleAuditEvidenceBundleExport(context.Background(), AuditEvidenceBundleExportRequest{ + SchemaID: "runecode.protocol.v0.AuditEvidenceBundleExportRequest", + SchemaVersion: "0.1.0", + RequestID: "req-audit-bundle-smoke-export", + Scope: AuditEvidenceBundleScope{ScopeKind: "run", RunID: "run-1"}, + ExportProfile: "external_relying_party_minimal", + CreatedByTool: AuditEvidenceBundleToolIdentity{ToolName: "runecode-broker", ToolVersion: "0.0.0-dev"}, + DisclosurePosture: AuditEvidenceBundleDisclosurePosture{ + Posture: "digest_metadata_only", + SelectiveDisclosureApplied: true, + }, + ArchiveFormat: "tar", + }, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleAuditEvidenceBundleExport returned error: %+v", errResp) + } + archiveBytes := gatherAuditBundleExportBytes(t, events) + if len(archiveBytes) == 0 { + t.Fatal("bundle export archive bytes empty") + } + _ = changeDigest + return archiveBytes +} + +func runWorkflowRunBundleSmokeDraftAndPromote(t *testing.T, service *Service) string { + t.Helper() + changeAck := mustSessionExecutionTrigger(t, service, SessionExecutionTriggerRequest{ + SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", + SchemaVersion: "0.1.0", + RequestID: "req-audit-bundle-smoke-change-draft", + SessionID: "sess-audit-bundle-smoke", + TriggerSource: "interactive_user", + RequestedOperation: "start", + WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationChangeDraft}, + UserMessageContentText: "Bundle smoke change draft", + }) + if changeAck.ExecutionState != "running" { + t.Fatalf("change draft ack execution_state = %q, want running", changeAck.ExecutionState) + } + changeGet := mustSessionGet(t, service, "req-audit-bundle-smoke-change-draft-get", "sess-audit-bundle-smoke") + if changeGet.Session.LatestTurnExecution == nil { + t.Fatal("latest_turn_execution missing after change draft") + } + changeExec := changeGet.Session.LatestTurnExecution + changeDigest := digestForRunStep(t, service, changeExec.PrimaryRunID, "session_execution/change_draft_artifact") + mustSessionExecutionTrigger(t, service, SessionExecutionTriggerRequest{ + SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", + SchemaVersion: "0.1.0", + RequestID: "req-audit-bundle-smoke-promote", + SessionID: "sess-audit-bundle-smoke", + TriggerSource: "interactive_user", + RequestedOperation: "start", + WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationDraftPromoteApply, BoundInputArtifacts: []SessionWorkflowPackBoundInputArtifact{{ArtifactRef: "change_draft_artifact", ArtifactDigest: changeDigest}}}, + UserMessageContentText: "Bundle smoke promote change draft", + }) + post := mustSessionGet(t, service, "req-audit-bundle-smoke-post", "sess-audit-bundle-smoke") + if post.Session.LatestTurnExecution == nil { + t.Fatal("latest_turn_execution missing after promote/apply") + } + return changeDigest +} + func exportAuditBundleFileForOfflineVerifyTest(t *testing.T, service *Service) (string, func()) { t.Helper() events, errResp := service.HandleAuditEvidenceBundleExport(context.Background(), AuditEvidenceBundleExportRequest{ diff --git a/internal/brokerapi/local_api_ops_posture_evidence_authoritative_state_test.go b/internal/brokerapi/local_api_ops_posture_evidence_authoritative_state_test.go new file mode 100644 index 00000000..a785dcad --- /dev/null +++ b/internal/brokerapi/local_api_ops_posture_evidence_authoritative_state_test.go @@ -0,0 +1,158 @@ +package brokerapi + +import ( + "context" + "strings" + "testing" + + "github.com/runecode-ai/runecode/internal/launcherbackend" + "github.com/runecode-ai/runecode/internal/policyengine" +) + +func TestRunDetailAuthoritativeStateIncludesBackendPostureSelectionEvidenceRefs(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + const runID = "run-backend-evidence" + const instanceID = "launcher-instance-1" + const selectorRunID = "instance-control:launcher-instance-1" + const manifestHash = "sha256:" + "1111111111111111111111111111111111111111111111111111111111111111" + const actionHash = "sha256:" + "3333333333333333333333333333333333333333333333333333333333333333" + const requestDigest = "sha256:" + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + const decisionDigest = "sha256:" + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + + _ = putRunScopedArtifactForLocalOpsTest(t, s, runID, "step-1") + policyRef := recordBackendPosturePolicyDecisionForRun(t, s, selectorRunID, manifestHash, actionHash, instanceID) + approvalID := recordBackendPostureApprovalForRun(t, s, runID, selectorRunID, policyRef, manifestHash, actionHash, requestDigest, decisionDigest, instanceID) + recordContainerRuntimeFactsForBackendEvidence(t, s, runID) + + runGet, errResp := s.HandleRunGet(context.Background(), RunGetRequest{SchemaID: "runecode.protocol.v0.RunGetRequest", SchemaVersion: "0.1.0", RequestID: "req-run-backend-evidence", RunID: runID}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleRunGet error response: %+v", errResp) + } + evidence := backendPostureSelectionEvidenceForState(t, runGet.Run.AuthoritativeState) + policyEvidence := backendPosturePolicyRefsFromEvidence(t, evidence) + if len(policyEvidence) == 0 || policyEvidence[0] != policyRef { + t.Fatalf("backend_posture_selection_evidence.policy_decision_refs = %v, want include %q", policyEvidence, policyRef) + } + if runGet.Run.AuthoritativeState["attestation_verifier_class"] != launcherbackend.AttestationVerifierClassUnknown { + t.Fatalf("authoritative_state.attestation_verifier_class = %v, want %q without persisted attestation evidence", runGet.Run.AuthoritativeState["attestation_verifier_class"], launcherbackend.AttestationVerifierClassUnknown) + } + if runGet.Run.AuthoritativeState["supported_runtime_requirements_satisfied"] != false { + t.Fatalf("authoritative_state.supported_runtime_requirements_satisfied = %v, want false without attestation evidence", runGet.Run.AuthoritativeState["supported_runtime_requirements_satisfied"]) + } + approvalEvidence := backendPostureApprovalEvidenceFromEvidence(t, evidence) + assertBackendPostureApprovalEvidence(t, approvalEvidence, approvalID, requestDigest, decisionDigest, policyRef) +} + +func TestRunDetailAuthoritativeStateBackendPostureSelectionEvidenceUsesBackendScopedPolicyRefs(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + const runID = "run-backend-evidence-scoped-refs" + const instanceID = "launcher-instance-1" + const selectorRunID = "instance-control:launcher-instance-1" + const manifestHash = "sha256:" + "1111111111111111111111111111111111111111111111111111111111111111" + const actionHash = "sha256:" + "3333333333333333333333333333333333333333333333333333333333333333" + const backendActionHash = "sha256:" + "4444444444444444444444444444444444444444444444444444444444444444" + const requestDigest = "sha256:" + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + const decisionDigest = "sha256:" + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + + _ = putRunScopedArtifactForLocalOpsTest(t, s, runID, "step-1") + genericRunPolicyRef := recordBackendPosturePolicyDecisionForRun(t, s, runID, manifestHash, actionHash, instanceID) + backendPolicyRef := recordBackendPosturePolicyDecisionForRun(t, s, selectorRunID, manifestHash, backendActionHash, instanceID) + recordBackendPostureApprovalForRun(t, s, runID, selectorRunID, backendPolicyRef, manifestHash, backendActionHash, requestDigest, decisionDigest, instanceID) + recordContainerRuntimeFactsForBackendEvidence(t, s, runID) + + runGet, errResp := s.HandleRunGet(context.Background(), RunGetRequest{SchemaID: "runecode.protocol.v0.RunGetRequest", SchemaVersion: "0.1.0", RequestID: "req-run-backend-evidence-scoped-refs", RunID: runID}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleRunGet error response: %+v", errResp) + } + evidence := backendPostureSelectionEvidenceForState(t, runGet.Run.AuthoritativeState) + policyEvidence := backendPosturePolicyRefsFromEvidence(t, evidence) + if !containsStringInSlice(policyEvidence, backendPolicyRef) { + t.Fatalf("backend_posture_selection_evidence.policy_decision_refs = %v, want include backend policy ref %q", policyEvidence, backendPolicyRef) + } + if containsStringInSlice(policyEvidence, genericRunPolicyRef) { + t.Fatalf("backend_posture_selection_evidence.policy_decision_refs = %v, should omit generic run policy ref %q", policyEvidence, genericRunPolicyRef) + } +} + +func TestBuildAuthoritativeRunStateUsesLaunchEvidenceAsReceiptSourceWhenPersisted(t *testing.T) { + runtimeFacts, runtimeEvidence := authoritativeRunStateEvidenceFixtures() + state := buildAuthoritativeRunState( + authoritativeRunStateSummaryFixture(), + nil, + nil, + nil, + nil, + nil, + runtimeFacts, + runtimeEvidence, + "", + ) + assertAuthoritativeStateUsesLaunchEvidence(t, state) +} + +func authoritativeRunStateEvidenceFixtures() (launcherbackend.RuntimeFactsSnapshot, launcherbackend.RuntimeEvidenceSnapshot) { + return launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: launcherbackend.BackendLaunchReceipt{ + RunID: "run-evidence-authoritative", + StageID: "artifact_flow", + RoleInstanceID: "workspace-1", + RoleFamily: "workspace", + BackendKind: launcherbackend.BackendKindContainer, + IsolationAssuranceLevel: launcherbackend.IsolationAssuranceDegraded, + ProvisioningPosture: launcherbackend.ProvisioningPostureTOFU, + IsolateID: "isolate-from-stale-receipt", + }}, launcherbackend.RuntimeEvidenceSnapshot{Launch: launcherbackend.LaunchRuntimeEvidence{ + RunID: "run-evidence-authoritative", + StageID: "artifact_flow", + RoleInstanceID: "workspace-1", + RoleFamily: "workspace", + RoleKind: "workspace-edit", + BackendKind: launcherbackend.BackendKindMicroVM, + IsolationAssuranceLevel: launcherbackend.IsolationAssuranceIsolated, + ProvisioningPosture: launcherbackend.ProvisioningPostureAttested, + IsolateID: "isolate-from-evidence", + EvidenceDigest: "sha256:" + strings.Repeat("1", 64), + }} +} + +func authoritativeRunStateSummaryFixture() RunSummary { + return RunSummary{RunID: "run-evidence-authoritative", WorkspaceID: "workspace-run-evidence-authoritative", LifecycleState: "active"} +} + +func assertAuthoritativeStateUsesLaunchEvidence(t *testing.T, state map[string]any) { + t.Helper() + if state["backend_kind"] != launcherbackend.BackendKindMicroVM { + t.Fatalf("authoritative_state.backend_kind = %v, want %q from launch evidence", state["backend_kind"], launcherbackend.BackendKindMicroVM) + } + if state["provisioning_posture"] != launcherbackend.ProvisioningPostureAttested { + t.Fatalf("authoritative_state.provisioning_posture = %v, want %q from launch evidence", state["provisioning_posture"], launcherbackend.ProvisioningPostureAttested) + } + if state["isolate_id"] != "isolate-from-evidence" { + t.Fatalf("authoritative_state.isolate_id = %v, want isolate-from-evidence from launch evidence", state["isolate_id"]) + } + if state["runtime_posture_degraded"] != false { + t.Fatalf("authoritative_state.runtime_posture_degraded = %v, want false from launch evidence posture", state["runtime_posture_degraded"]) + } + if state["attestation_verifier_class"] != launcherbackend.AttestationVerifierClassUnknown { + t.Fatalf("authoritative_state.attestation_verifier_class = %v, want %q when no attestation evidence is present", state["attestation_verifier_class"], launcherbackend.AttestationVerifierClassUnknown) + } +} + +func TestBuildAuthoritativeRunStateProjectsVerifierClassAndSupportedRuntimeRequirements(t *testing.T) { + runtimeFacts := launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: launcherbackend.BackendLaunchReceipt{RunID: "run-container-attested", StageID: "artifact_flow", RoleInstanceID: "workspace-1", RoleFamily: "workspace", BackendKind: launcherbackend.BackendKindContainer, IsolationAssuranceLevel: launcherbackend.IsolationAssuranceDegraded, ProvisioningPosture: launcherbackend.ProvisioningPostureAttested}} + runtimeEvidence := launcherbackend.RuntimeEvidenceSnapshot{ + Launch: launcherbackend.LaunchRuntimeEvidence{RunID: "run-container-attested", StageID: "artifact_flow", RoleInstanceID: "workspace-1", RoleFamily: "workspace", RoleKind: "workspace-edit", BackendKind: launcherbackend.BackendKindContainer, IsolationAssuranceLevel: launcherbackend.IsolationAssuranceDegraded, ProvisioningPosture: launcherbackend.ProvisioningPostureAttested, EvidenceDigest: "sha256:" + strings.Repeat("1", 64)}, + Attestation: &launcherbackend.IsolateAttestationEvidence{AttestationSourceKind: launcherbackend.AttestationSourceKindTrustedRuntime, MeasurementProfile: launcherbackend.MeasurementProfileContainerImageV1, EvidenceDigest: "sha256:" + strings.Repeat("2", 64)}, + AttestationVerification: &launcherbackend.IsolateAttestationVerificationRecord{VerificationResult: launcherbackend.AttestationVerificationResultValid, ReplayVerdict: launcherbackend.AttestationReplayVerdictOriginal, VerificationDigest: "sha256:" + strings.Repeat("3", 64)}, + } + approvals := []ApprovalSummary{{ApprovalID: "ap-1", Status: "consumed", PolicyDecisionHash: "sha256:" + strings.Repeat("4", 64), BoundScope: ApprovalBoundScope{ActionKind: policyengine.ActionKindBackendPosture, InstanceID: "launcher-instance-1", RunID: "instance-control:launcher-instance-1"}}} + state := buildAuthoritativeRunState(RunSummary{RunID: "run-container-attested", WorkspaceID: "workspace-run-container-attested", LifecycleState: "active"}, nil, nil, nil, nil, approvals, runtimeFacts, runtimeEvidence, "launcher-instance-1") + if state["attestation_verifier_class"] != launcherbackend.AttestationVerifierClassTrustedDomainLocal { + t.Fatalf("authoritative_state.attestation_verifier_class = %v, want %q", state["attestation_verifier_class"], launcherbackend.AttestationVerifierClassTrustedDomainLocal) + } + if state["reduced_assurance_approval_backed"] != true { + t.Fatalf("authoritative_state.reduced_assurance_approval_backed = %v, want true", state["reduced_assurance_approval_backed"]) + } + if state["supported_runtime_requirements_satisfied"] != true { + t.Fatalf("authoritative_state.supported_runtime_requirements_satisfied = %v, want true", state["supported_runtime_requirements_satisfied"]) + } +} diff --git a/internal/brokerapi/local_api_ops_posture_evidence_runtime_test.go b/internal/brokerapi/local_api_ops_posture_evidence_runtime_test.go new file mode 100644 index 00000000..feb6461e --- /dev/null +++ b/internal/brokerapi/local_api_ops_posture_evidence_runtime_test.go @@ -0,0 +1,214 @@ +package brokerapi + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/runecode-ai/runecode/internal/launcherbackend" +) + +func TestRunIdentityOmitsBackendSpecificProvenanceForContainerRunSummary(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + const runID = "run-container-identity" + _ = putRunScopedArtifactForLocalOpsTest(t, s, runID, "step-1") + recordContainerIdentityRuntimeFacts(t, s, runID) + + run := fetchSingleRunSummary(t, s, "req-run-container-identity") + assertContainerSummaryIdentityFields(t, run) + assertSummaryOmitsBackendSpecificProvenance(t, run) +} + +func TestRunSummaryKeepsAuditPostureDistinctFromBackendAndRuntimePosture(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + const runID = "run-posture-separation" + _ = putRunScopedArtifactForLocalOpsTest(t, s, runID, "step-1") + if err := s.RecordRuntimeFacts(runID, launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: launcherbackend.BackendLaunchReceipt{ + RunID: runID, + StageID: "artifact_flow", + RoleInstanceID: "workspace-1", + RoleFamily: "workspace", + BackendKind: launcherbackend.BackendKindContainer, + IsolationAssuranceLevel: launcherbackend.IsolationAssuranceDegraded, + ProvisioningPosture: launcherbackend.ProvisioningPostureAttested, + }}); err != nil { + t.Fatalf("RecordRuntimeFacts returned error: %v", err) + } + s.auditLedger = nil + + run := fetchSingleRunSummary(t, s, "req-run-posture-separation") + if run.BackendKind != launcherbackend.BackendKindContainer || run.IsolationAssuranceLevel != launcherbackend.IsolationAssuranceDegraded || !run.RuntimePostureDegraded { + t.Fatalf("runtime posture projection changed unexpectedly: %+v", run) + } + if !run.AuditCurrentlyDegraded || run.AuditIntegrityStatus != "degraded" || run.AuditAnchoringStatus != "degraded" { + t.Fatalf("audit posture should degrade independently when verification unavailable: %+v", run) + } +} + +func TestRunDetailAuthoritativeStateKeepsSyntheticReceiptAttestationUnsupportedAcrossBackends(t *testing.T) { + tests := []struct { + name string + backend string + isolation string + }{ + {name: "microvm", backend: launcherbackend.BackendKindMicroVM, isolation: launcherbackend.IsolationAssuranceIsolated}, + {name: "container", backend: launcherbackend.BackendKindContainer, isolation: launcherbackend.IsolationAssuranceDegraded}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + state, evidence := recordAndFetchSyntheticReceiptOnlyAttestation(t, tc.backend, tc.isolation) + assertSyntheticReceiptOnlyAuthoritativeState(t, state) + assertSyntheticReceiptOnlyRuntimeEvidence(t, evidence) + }) + } +} + +func recordAndFetchSyntheticReceiptOnlyAttestation(t *testing.T, backend string, isolation string) (map[string]any, launcherbackend.RuntimeEvidenceSnapshot) { + t.Helper() + s := newBrokerAPIServiceForTests(t, APIConfig{}) + runID := "run-synthetic-receipt-only-" + backend + _ = putRunScopedArtifactForLocalOpsTest(t, s, runID, "step-1") + if err := s.RecordRuntimeFacts(runID, syntheticReceiptOnlyAttestationFacts(runID, backend, isolation)); err != nil { + t.Fatalf("RecordRuntimeFacts returned error: %v", err) + } + runGet, errResp := s.HandleRunGet(context.Background(), RunGetRequest{SchemaID: "runecode.protocol.v0.RunGetRequest", SchemaVersion: "0.1.0", RequestID: "req-run-synthetic-receipt", RunID: runID}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleRunGet error response: %+v", errResp) + } + return runGet.Run.AuthoritativeState, s.RuntimeEvidence(runID) +} + +func assertSyntheticReceiptOnlyAuthoritativeState(t *testing.T, state map[string]any) { + t.Helper() + if state["provisioning_posture"] != launcherbackend.ProvisioningPostureTOFU { + t.Fatalf("authoritative_state.provisioning_posture = %v, want %q", state["provisioning_posture"], launcherbackend.ProvisioningPostureTOFU) + } + if state["supported_runtime_requirements_satisfied"] != false { + t.Fatalf("authoritative_state.supported_runtime_requirements_satisfied = %v, want false for synthetic receipt-only attestation", state["supported_runtime_requirements_satisfied"]) + } + if state["attestation_posture"] == launcherbackend.AttestationPostureValid { + t.Fatalf("authoritative_state.attestation_posture = %v, want not %q", state["attestation_posture"], launcherbackend.AttestationPostureValid) + } + if state["attestation_evidence_present"] != false { + t.Fatalf("authoritative_state.attestation_evidence_present = %v, want false", state["attestation_evidence_present"]) + } + if got := renderTruthfulnessShapeFromAuthoritativeState(state); got != "secure session bound without verified attestation; beta attested story still gated by post-handshake verification" { + t.Fatalf("truthfulness cue = %q, want secure-session-bound truthful wording", got) + } +} + +func assertSyntheticReceiptOnlyRuntimeEvidence(t *testing.T, evidence launcherbackend.RuntimeEvidenceSnapshot) { + t.Helper() + if evidence.Attestation != nil { + t.Fatalf("runtime evidence attestation = %#v, want nil without post-handshake evidence", evidence.Attestation) + } + if evidence.AttestationVerification == nil { + t.Fatal("runtime evidence attestation verification missing") + } + if evidence.AttestationVerification.VerificationResult != launcherbackend.AttestationVerificationResultInvalid { + t.Fatalf("runtime evidence verification_result = %q, want %q", evidence.AttestationVerification.VerificationResult, launcherbackend.AttestationVerificationResultInvalid) + } + if !containsStringInSlice(evidence.AttestationVerification.ReasonCodes, "attestation_post_handshake_input_required") { + t.Fatalf("runtime evidence reason_codes = %v, want include attestation_post_handshake_input_required", evidence.AttestationVerification.ReasonCodes) + } +} + +func renderTruthfulnessShapeFromAuthoritativeState(state map[string]any) string { + posture, reasons := attestationTruthfulnessStateForTest(state) + verificationSucceeded, _ := state["attestation_verification_succeeded"].(bool) + sessionBindingPresent, _ := state["session_binding_present"].(bool) + attestationEvidencePresent, _ := state["attestation_evidence_present"].(bool) + supportedRuntimeSatisfied, _ := state["supported_runtime_requirements_satisfied"].(bool) + currentEvidence := "launch-only evidence" + switch { + case verificationSucceeded: + currentEvidence = "post-handshake verification succeeded" + case attestationEvidencePresent: + currentEvidence = "post-handshake evidence collected but not yet supportable" + case sessionBindingPresent: + currentEvidence = "secure session bound without verified attestation" + } + if supportedRuntimeSatisfied && posture == launcherbackend.AttestationPostureValid { + return currentEvidence + "; supported attested posture earned from verified post-handshake evidence" + } + if len(reasons) > 0 { + return currentEvidence + "; beta attested story still gated by post-handshake verification; reasons=" + strings.Join(reasons, ",") + } + return currentEvidence + "; beta attested story still gated by post-handshake verification" +} + +func attestationTruthfulnessStateForTest(state map[string]any) (string, []string) { + posture, _ := state["attestation_posture"].(string) + if reasons, ok := state["attestation_reason_codes"].([]string); ok { + return posture, append([]string{}, reasons...) + } + reasonsAny, _ := state["attestation_reason_codes"].([]any) + reasons := make([]string, 0, len(reasonsAny)) + for _, value := range reasonsAny { + if s, ok := value.(string); ok && strings.TrimSpace(s) != "" { + reasons = append(reasons, s) + } + } + return posture, reasons +} + +func recordContainerIdentityRuntimeFacts(t *testing.T, s *Service, runID string) { + t.Helper() + if err := s.RecordRuntimeFacts(runID, launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: launcherbackend.BackendLaunchReceipt{ + RunID: runID, + StageID: "artifact_flow", + RoleInstanceID: "workspace-1", + RoleFamily: "workspace", + BackendKind: launcherbackend.BackendKindContainer, + IsolationAssuranceLevel: launcherbackend.IsolationAssuranceDegraded, + ProvisioningPosture: launcherbackend.ProvisioningPostureAttested, + HypervisorImplementation: launcherbackend.HypervisorImplementationNotApplicable, + AccelerationKind: launcherbackend.AccelerationKindNotApplicable, + TransportKind: launcherbackend.TransportKindNotApplicable, + QEMUProvenance: &launcherbackend.QEMUProvenance{Version: "9.1.0", BuildIdentity: "qemu-system-x86_64"}, + RuntimeImageDescriptorDigest: "sha256:" + strings.Repeat("d", 64), + }}); err != nil { + t.Fatalf("RecordRuntimeFacts returned error: %v", err) + } +} + +func fetchSingleRunSummary(t *testing.T, s *Service, requestID string) RunSummary { + t.Helper() + runList, errResp := s.HandleRunList(context.Background(), RunListRequest{SchemaID: "runecode.protocol.v0.RunListRequest", SchemaVersion: "0.1.0", RequestID: requestID, Limit: 10}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleRunList error response: %+v", errResp) + } + if len(runList.Runs) != 1 { + t.Fatalf("run count = %d, want 1", len(runList.Runs)) + } + return runList.Runs[0] +} + +func assertContainerSummaryIdentityFields(t *testing.T, run RunSummary) { + t.Helper() + if run.BackendKind != launcherbackend.BackendKindContainer { + t.Fatalf("summary.backend_kind = %q, want %q", run.BackendKind, launcherbackend.BackendKindContainer) + } + if run.IsolationAssuranceLevel != launcherbackend.IsolationAssuranceDegraded { + t.Fatalf("summary.isolation_assurance_level = %q, want %q", run.IsolationAssuranceLevel, launcherbackend.IsolationAssuranceDegraded) + } + if run.ProvisioningPosture != launcherbackend.ProvisioningPostureTOFU { + t.Fatalf("summary.provisioning_posture = %q, want %q", run.ProvisioningPosture, launcherbackend.ProvisioningPostureTOFU) + } +} + +func assertSummaryOmitsBackendSpecificProvenance(t *testing.T, run RunSummary) { + t.Helper() + payload, err := json.Marshal(run) + if err != nil { + t.Fatalf("json.Marshal returned error: %v", err) + } + serialized := string(payload) + for _, forbidden := range []string{"qemu_provenance", "hypervisor_implementation", "transport_kind", "runtime_image_descriptor_digest"} { + if strings.Contains(serialized, forbidden) { + t.Fatalf("run summary identity contains backend-specific provenance field %q: %s", forbidden, serialized) + } + } +} diff --git a/internal/brokerapi/local_api_ops_posture_evidence_shared_test.go b/internal/brokerapi/local_api_ops_posture_evidence_shared_test.go new file mode 100644 index 00000000..02f98cd1 --- /dev/null +++ b/internal/brokerapi/local_api_ops_posture_evidence_shared_test.go @@ -0,0 +1,216 @@ +package brokerapi + +import ( + "strings" + "testing" + "time" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/launcherbackend" + "github.com/runecode-ai/runecode/internal/policyengine" +) + +func syntheticReceiptOnlyAttestationFacts(runID string, backend string, isolation string) launcherbackend.RuntimeFactsSnapshot { + bootProfile, measurementProfile, bootByName, measurementDigests := syntheticReceiptOnlyAttestationIdentity(backend) + receipt := syntheticReceiptOnlyAttestationLaunchReceipt(runID, backend, isolation, bootProfile, measurementProfile, bootByName, measurementDigests) + return launcherbackend.RuntimeFactsSnapshot{ + LaunchReceipt: receipt, + HardeningPosture: syntheticReceiptOnlyAttestationHardeningPosture(), + } +} + +func syntheticReceiptOnlyAttestationLaunchReceipt(runID, backend, isolation, bootProfile, measurementProfile string, bootByName map[string]string, measurementDigests []string) launcherbackend.BackendLaunchReceipt { + return launcherbackend.BackendLaunchReceipt{ + RunID: runID, + StageID: "artifact_flow", + RoleInstanceID: "workspace-1", + RoleFamily: "workspace", + RoleKind: "workspace-edit", + BackendKind: backend, + IsolationAssuranceLevel: isolation, + ProvisioningPosture: launcherbackend.ProvisioningPostureAttested, + IsolateID: "isolate-synthetic", + SessionID: "session-synthetic", + SessionNonce: "nonce-synthetic-0123456789abcdef", + LaunchContextDigest: "sha256:" + strings.Repeat("c", 64), + HandshakeTranscriptHash: "sha256:" + strings.Repeat("d", 64), + IsolateSessionKeyIDValue: strings.Repeat("e", 64), + SessionSecurity: &launcherbackend.SessionSecurityPosture{MutuallyAuthenticated: true, Encrypted: true, ProofOfPossessionVerified: true, ReplayProtected: true}, + RuntimeImageDescriptorDigest: "sha256:" + strings.Repeat("f", 64), + RuntimeImageBootProfile: bootProfile, + BootComponentDigestByName: bootByName, + BootComponentDigests: append([]string{}, measurementDigests...), + AttestationEvidenceSourceKind: launcherbackend.AttestationSourceKindTrustedRuntime, + AttestationMeasurementProfile: measurementProfile, + AttestationFreshnessMaterial: []string{"session_nonce"}, + AttestationFreshnessBindingClaims: []string{"session_nonce", "handshake_transcript_hash", "launch_context_digest"}, + AttestationEvidenceClaimsDigest: measurementDigests[0], + CachePosture: syntheticReceiptOnlyAttestationCachePosture(), + } +} + +func syntheticReceiptOnlyAttestationCachePosture() *launcherbackend.BackendCachePosture { + return &launcherbackend.BackendCachePosture{WarmPoolEnabled: true, BootCacheEnabled: true, ResetOrDestroyBeforeReuse: false, ReusePriorSessionIdentityKeys: true, DigestPinned: true, SignaturePinned: true} +} + +func syntheticReceiptOnlyAttestationHardeningPosture() launcherbackend.AppliedHardeningPosture { + return launcherbackend.AppliedHardeningPosture{ + Requested: launcherbackend.HardeningRequestedHardened, + Effective: launcherbackend.HardeningEffectiveHardened, + ExecutionIdentityPosture: launcherbackend.HardeningExecutionIdentityUnprivileged, + FilesystemExposurePosture: launcherbackend.HardeningFilesystemExposureRestricted, + NetworkExposurePosture: launcherbackend.HardeningNetworkExposureNone, + SyscallFilteringPosture: launcherbackend.HardeningSyscallFilteringSeccomp, + DeviceSurfacePosture: launcherbackend.HardeningDeviceSurfaceAllowlist, + } +} + +func syntheticReceiptOnlyAttestationIdentity(backend string) (string, string, map[string]string, []string) { + bootByName := map[string]string{"kernel": "sha256:" + strings.Repeat("a", 64), "initrd": "sha256:" + strings.Repeat("b", 64)} + bootProfile := launcherbackend.BootProfileMicroVMLinuxKernelInitrdV1 + measurementProfile := launcherbackend.MeasurementProfileMicroVMBootV1 + if backend == launcherbackend.BackendKindContainer { + bootByName = map[string]string{"image": "sha256:" + strings.Repeat("a", 64)} + bootProfile = launcherbackend.BootProfileContainerOCIImageV1 + measurementProfile = launcherbackend.MeasurementProfileContainerImageV1 + } + measurementDigests, err := launcherbackend.DeriveExpectedMeasurementDigests(measurementProfile, bootProfile, bootByName) + if err != nil { + panic(err) + } + return bootProfile, measurementProfile, bootByName, measurementDigests +} + +func recordBackendPosturePolicyDecisionForRun(t *testing.T, s *Service, runID, manifestHash, actionHash, instanceID string) string { + t.Helper() + decision := policyengine.PolicyDecision{ + SchemaID: "runecode.protocol.v0.PolicyDecision", + SchemaVersion: "0.3.0", + DecisionOutcome: policyengine.DecisionDeny, + PolicyReasonCode: "deny_by_default", + ManifestHash: manifestHash, + PolicyInputHashes: []string{"sha256:" + strings.Repeat("2", 64)}, + ActionRequestHash: actionHash, + RelevantArtifactHashes: []string{"sha256:" + strings.Repeat("4", 64)}, + DetailsSchemaID: "runecode.protocol.details.policy.evaluation.v0", + Details: map[string]any{"precedence": "approval_profile_moderate", "instance_id": instanceID}, + } + if err := s.RecordPolicyDecision(runID, "", decision); err != nil { + t.Fatalf("RecordPolicyDecision returned error: %v", err) + } + refs := s.PolicyDecisionRefsForRun(runID) + if len(refs) == 0 { + t.Fatal("PolicyDecisionRefsForRun returned empty refs") + } + return refs[0] +} + +func recordBackendPostureApprovalForRun(t *testing.T, s *Service, runID, selectorRunID, policyRef, manifestHash, actionHash, requestDigest, decisionDigest, instanceID string) string { + t.Helper() + approvalID := "sha256:" + strings.Repeat("a", 64) + now := time.Now().UTC().Round(0) + if err := s.RecordApproval(artifacts.ApprovalRecord{ + ApprovalID: approvalID, + Status: "consumed", + WorkspaceID: workspaceIDForRun(runID), + InstanceID: instanceID, + RunID: selectorRunID, + ActionKind: policyengine.ActionKindBackendPosture, + RequestedAt: now.Add(-2 * time.Minute), + DecidedAt: func() *time.Time { t := now.Add(-1 * time.Minute); return &t }(), + ConsumedAt: func() *time.Time { t := now; return &t }(), + ApprovalTriggerCode: "reduced_assurance_backend", + ChangesIfApproved: "Reduced-assurance backend posture change may be applied.", + ApprovalAssuranceLevel: "reauthenticated", + PresenceMode: "hardware_touch", + PolicyDecisionHash: policyRef, + ManifestHash: manifestHash, + ActionRequestHash: actionHash, + RequestDigest: requestDigest, + DecisionDigest: decisionDigest, + }); err != nil { + t.Fatalf("RecordApproval returned error: %v", err) + } + return approvalID +} + +func recordContainerRuntimeFactsForBackendEvidence(t *testing.T, s *Service, runID string) { + t.Helper() + if err := s.RecordRuntimeFacts(runID, launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: launcherbackend.BackendLaunchReceipt{ + RunID: runID, + StageID: "artifact_flow", + RoleInstanceID: "workspace-1", + RoleFamily: "workspace", + BackendKind: launcherbackend.BackendKindContainer, + IsolationAssuranceLevel: launcherbackend.IsolationAssuranceDegraded, + ProvisioningPosture: launcherbackend.ProvisioningPostureAttested, + }}); err != nil { + t.Fatalf("RecordRuntimeFacts returned error: %v", err) + } +} + +func backendPostureSelectionEvidenceForState(t *testing.T, state map[string]any) map[string]any { + t.Helper() + evidence, ok := state["backend_posture_selection_evidence"].(map[string]any) + if !ok { + t.Fatalf("authoritative_state.backend_posture_selection_evidence = %T, want map", state["backend_posture_selection_evidence"]) + } + return evidence +} + +func backendPosturePolicyRefsFromEvidence(t *testing.T, evidence map[string]any) []string { + t.Helper() + if refs, ok := evidence["policy_decision_refs"].([]string); ok { + return refs + } + refsAny, ok := evidence["policy_decision_refs"].([]any) + if !ok { + t.Fatalf("backend_posture_selection_evidence.policy_decision_refs = %T, want []string", evidence["policy_decision_refs"]) + } + refs := make([]string, 0, len(refsAny)) + for _, item := range refsAny { + value, ok := item.(string) + if !ok { + t.Fatalf("policy_decision_refs entry = %T, want string", item) + } + refs = append(refs, value) + } + return refs +} + +func backendPostureApprovalEvidenceFromEvidence(t *testing.T, evidence map[string]any) map[string]any { + t.Helper() + approvalEvidence, ok := evidence["approval"].(map[string]any) + if !ok { + t.Fatalf("backend_posture_selection_evidence.approval = %T, want map", evidence["approval"]) + } + return approvalEvidence +} + +func containsStringInSlice(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func assertBackendPostureApprovalEvidence(t *testing.T, approvalEvidence map[string]any, approvalID, requestDigest, decisionDigest, policyRef string) { + t.Helper() + if approvalEvidence["approval_id"] != approvalID { + t.Fatalf("backend_posture_selection_evidence.approval.approval_id = %v, want %q", approvalEvidence["approval_id"], approvalID) + } + if approvalEvidence["approval_request_digest"] != requestDigest { + t.Fatalf("backend_posture_selection_evidence.approval.approval_request_digest = %v, want %q", approvalEvidence["approval_request_digest"], requestDigest) + } + if approvalEvidence["approval_decision_digest"] != decisionDigest { + t.Fatalf("backend_posture_selection_evidence.approval.approval_decision_digest = %v, want %q", approvalEvidence["approval_decision_digest"], decisionDigest) + } + if approvalEvidence["policy_decision_hash"] != policyRef { + t.Fatalf("backend_posture_selection_evidence.approval.policy_decision_hash = %v, want %q", approvalEvidence["policy_decision_hash"], policyRef) + } + if approvalEvidence["status"] != "consumed" { + t.Fatalf("backend_posture_selection_evidence.approval.status = %v, want consumed", approvalEvidence["status"]) + } +} diff --git a/internal/brokerapi/local_api_ops_posture_evidence_test.go b/internal/brokerapi/local_api_ops_posture_evidence_test.go index 02aa0b99..e2f79ff0 100644 --- a/internal/brokerapi/local_api_ops_posture_evidence_test.go +++ b/internal/brokerapi/local_api_ops_posture_evidence_test.go @@ -1,391 +1 @@ package brokerapi - -import ( - "context" - "encoding/json" - "strings" - "testing" - "time" - - "github.com/runecode-ai/runecode/internal/artifacts" - "github.com/runecode-ai/runecode/internal/launcherbackend" - "github.com/runecode-ai/runecode/internal/policyengine" -) - -func TestRunDetailAuthoritativeStateIncludesBackendPostureSelectionEvidenceRefs(t *testing.T) { - s := newBrokerAPIServiceForTests(t, APIConfig{}) - const runID = "run-backend-evidence" - const instanceID = "launcher-instance-1" - const selectorRunID = "instance-control:launcher-instance-1" - const manifestHash = "sha256:" + "1111111111111111111111111111111111111111111111111111111111111111" - const actionHash = "sha256:" + "3333333333333333333333333333333333333333333333333333333333333333" - const requestDigest = "sha256:" + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - const decisionDigest = "sha256:" + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" - - _ = putRunScopedArtifactForLocalOpsTest(t, s, runID, "step-1") - policyRef := recordBackendPosturePolicyDecisionForRun(t, s, selectorRunID, manifestHash, actionHash, instanceID) - approvalID := recordBackendPostureApprovalForRun(t, s, runID, selectorRunID, policyRef, manifestHash, actionHash, requestDigest, decisionDigest, instanceID) - recordContainerRuntimeFactsForBackendEvidence(t, s, runID) - - runGet, errResp := s.HandleRunGet(context.Background(), RunGetRequest{SchemaID: "runecode.protocol.v0.RunGetRequest", SchemaVersion: "0.1.0", RequestID: "req-run-backend-evidence", RunID: runID}, RequestContext{}) - if errResp != nil { - t.Fatalf("HandleRunGet error response: %+v", errResp) - } - evidence := backendPostureSelectionEvidenceForState(t, runGet.Run.AuthoritativeState) - policyEvidence := backendPosturePolicyRefsFromEvidence(t, evidence) - if len(policyEvidence) == 0 || policyEvidence[0] != policyRef { - t.Fatalf("backend_posture_selection_evidence.policy_decision_refs = %v, want include %q", policyEvidence, policyRef) - } - if runGet.Run.AuthoritativeState["attestation_verifier_class"] != launcherbackend.AttestationVerifierClassUnknown { - t.Fatalf("authoritative_state.attestation_verifier_class = %v, want %q without persisted attestation evidence", runGet.Run.AuthoritativeState["attestation_verifier_class"], launcherbackend.AttestationVerifierClassUnknown) - } - if runGet.Run.AuthoritativeState["supported_runtime_requirements_satisfied"] != false { - t.Fatalf("authoritative_state.supported_runtime_requirements_satisfied = %v, want false without attestation evidence", runGet.Run.AuthoritativeState["supported_runtime_requirements_satisfied"]) - } - approvalEvidence := backendPostureApprovalEvidenceFromEvidence(t, evidence) - assertBackendPostureApprovalEvidence(t, approvalEvidence, approvalID, requestDigest, decisionDigest, policyRef) -} - -func TestRunDetailAuthoritativeStateBackendPostureSelectionEvidenceUsesBackendScopedPolicyRefs(t *testing.T) { - s := newBrokerAPIServiceForTests(t, APIConfig{}) - const runID = "run-backend-evidence-scoped-refs" - const instanceID = "launcher-instance-1" - const selectorRunID = "instance-control:launcher-instance-1" - const manifestHash = "sha256:" + "1111111111111111111111111111111111111111111111111111111111111111" - const actionHash = "sha256:" + "3333333333333333333333333333333333333333333333333333333333333333" - const backendActionHash = "sha256:" + "4444444444444444444444444444444444444444444444444444444444444444" - const requestDigest = "sha256:" + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - const decisionDigest = "sha256:" + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" - - _ = putRunScopedArtifactForLocalOpsTest(t, s, runID, "step-1") - genericRunPolicyRef := recordBackendPosturePolicyDecisionForRun(t, s, runID, manifestHash, actionHash, instanceID) - backendPolicyRef := recordBackendPosturePolicyDecisionForRun(t, s, selectorRunID, manifestHash, backendActionHash, instanceID) - recordBackendPostureApprovalForRun(t, s, runID, selectorRunID, backendPolicyRef, manifestHash, backendActionHash, requestDigest, decisionDigest, instanceID) - recordContainerRuntimeFactsForBackendEvidence(t, s, runID) - - runGet, errResp := s.HandleRunGet(context.Background(), RunGetRequest{SchemaID: "runecode.protocol.v0.RunGetRequest", SchemaVersion: "0.1.0", RequestID: "req-run-backend-evidence-scoped-refs", RunID: runID}, RequestContext{}) - if errResp != nil { - t.Fatalf("HandleRunGet error response: %+v", errResp) - } - evidence := backendPostureSelectionEvidenceForState(t, runGet.Run.AuthoritativeState) - policyEvidence := backendPosturePolicyRefsFromEvidence(t, evidence) - if !containsStringInSlice(policyEvidence, backendPolicyRef) { - t.Fatalf("backend_posture_selection_evidence.policy_decision_refs = %v, want include backend policy ref %q", policyEvidence, backendPolicyRef) - } - if containsStringInSlice(policyEvidence, genericRunPolicyRef) { - t.Fatalf("backend_posture_selection_evidence.policy_decision_refs = %v, should omit generic run policy ref %q", policyEvidence, genericRunPolicyRef) - } -} - -func TestBuildAuthoritativeRunStateUsesLaunchEvidenceAsReceiptSourceWhenPersisted(t *testing.T) { - runtimeFacts, runtimeEvidence := authoritativeRunStateEvidenceFixtures() - state := buildAuthoritativeRunState( - authoritativeRunStateSummaryFixture(), - nil, - nil, - nil, - nil, - nil, - runtimeFacts, - runtimeEvidence, - "", - ) - assertAuthoritativeStateUsesLaunchEvidence(t, state) -} - -func authoritativeRunStateEvidenceFixtures() (launcherbackend.RuntimeFactsSnapshot, launcherbackend.RuntimeEvidenceSnapshot) { - return launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: launcherbackend.BackendLaunchReceipt{ - RunID: "run-evidence-authoritative", - StageID: "artifact_flow", - RoleInstanceID: "workspace-1", - RoleFamily: "workspace", - BackendKind: launcherbackend.BackendKindContainer, - IsolationAssuranceLevel: launcherbackend.IsolationAssuranceDegraded, - ProvisioningPosture: launcherbackend.ProvisioningPostureTOFU, - IsolateID: "isolate-from-stale-receipt", - }}, launcherbackend.RuntimeEvidenceSnapshot{Launch: launcherbackend.LaunchRuntimeEvidence{ - RunID: "run-evidence-authoritative", - StageID: "artifact_flow", - RoleInstanceID: "workspace-1", - RoleFamily: "workspace", - RoleKind: "workspace-edit", - BackendKind: launcherbackend.BackendKindMicroVM, - IsolationAssuranceLevel: launcherbackend.IsolationAssuranceIsolated, - ProvisioningPosture: launcherbackend.ProvisioningPostureAttested, - IsolateID: "isolate-from-evidence", - EvidenceDigest: "sha256:" + strings.Repeat("1", 64), - }} -} - -func authoritativeRunStateSummaryFixture() RunSummary { - return RunSummary{RunID: "run-evidence-authoritative", WorkspaceID: "workspace-run-evidence-authoritative", LifecycleState: "active"} -} - -func assertAuthoritativeStateUsesLaunchEvidence(t *testing.T, state map[string]any) { - t.Helper() - if state["backend_kind"] != launcherbackend.BackendKindMicroVM { - t.Fatalf("authoritative_state.backend_kind = %v, want %q from launch evidence", state["backend_kind"], launcherbackend.BackendKindMicroVM) - } - if state["provisioning_posture"] != launcherbackend.ProvisioningPostureAttested { - t.Fatalf("authoritative_state.provisioning_posture = %v, want %q from launch evidence", state["provisioning_posture"], launcherbackend.ProvisioningPostureAttested) - } - if state["isolate_id"] != "isolate-from-evidence" { - t.Fatalf("authoritative_state.isolate_id = %v, want isolate-from-evidence from launch evidence", state["isolate_id"]) - } - if state["runtime_posture_degraded"] != false { - t.Fatalf("authoritative_state.runtime_posture_degraded = %v, want false from launch evidence posture", state["runtime_posture_degraded"]) - } - if state["attestation_verifier_class"] != launcherbackend.AttestationVerifierClassUnknown { - t.Fatalf("authoritative_state.attestation_verifier_class = %v, want %q when no attestation evidence is present", state["attestation_verifier_class"], launcherbackend.AttestationVerifierClassUnknown) - } -} - -func TestBuildAuthoritativeRunStateProjectsVerifierClassAndSupportedRuntimeRequirements(t *testing.T) { - runtimeFacts := launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: launcherbackend.BackendLaunchReceipt{RunID: "run-container-attested", StageID: "artifact_flow", RoleInstanceID: "workspace-1", RoleFamily: "workspace", BackendKind: launcherbackend.BackendKindContainer, IsolationAssuranceLevel: launcherbackend.IsolationAssuranceDegraded, ProvisioningPosture: launcherbackend.ProvisioningPostureAttested}} - runtimeEvidence := launcherbackend.RuntimeEvidenceSnapshot{ - Launch: launcherbackend.LaunchRuntimeEvidence{RunID: "run-container-attested", StageID: "artifact_flow", RoleInstanceID: "workspace-1", RoleFamily: "workspace", RoleKind: "workspace-edit", BackendKind: launcherbackend.BackendKindContainer, IsolationAssuranceLevel: launcherbackend.IsolationAssuranceDegraded, ProvisioningPosture: launcherbackend.ProvisioningPostureAttested, EvidenceDigest: "sha256:" + strings.Repeat("1", 64)}, - Attestation: &launcherbackend.IsolateAttestationEvidence{AttestationSourceKind: launcherbackend.AttestationSourceKindTrustedRuntime, MeasurementProfile: launcherbackend.MeasurementProfileContainerImageV1, EvidenceDigest: "sha256:" + strings.Repeat("2", 64)}, - AttestationVerification: &launcherbackend.IsolateAttestationVerificationRecord{VerificationResult: launcherbackend.AttestationVerificationResultValid, ReplayVerdict: launcherbackend.AttestationReplayVerdictOriginal, VerificationDigest: "sha256:" + strings.Repeat("3", 64)}, - } - approvals := []ApprovalSummary{{ApprovalID: "ap-1", Status: "consumed", PolicyDecisionHash: "sha256:" + strings.Repeat("4", 64), BoundScope: ApprovalBoundScope{ActionKind: policyengine.ActionKindBackendPosture, InstanceID: "launcher-instance-1", RunID: "instance-control:launcher-instance-1"}}} - state := buildAuthoritativeRunState(RunSummary{RunID: "run-container-attested", WorkspaceID: "workspace-run-container-attested", LifecycleState: "active"}, nil, nil, nil, nil, approvals, runtimeFacts, runtimeEvidence, "launcher-instance-1") - if state["attestation_verifier_class"] != launcherbackend.AttestationVerifierClassTrustedDomainLocal { - t.Fatalf("authoritative_state.attestation_verifier_class = %v, want %q", state["attestation_verifier_class"], launcherbackend.AttestationVerifierClassTrustedDomainLocal) - } - if state["reduced_assurance_approval_backed"] != true { - t.Fatalf("authoritative_state.reduced_assurance_approval_backed = %v, want true", state["reduced_assurance_approval_backed"]) - } - if state["supported_runtime_requirements_satisfied"] != true { - t.Fatalf("authoritative_state.supported_runtime_requirements_satisfied = %v, want true", state["supported_runtime_requirements_satisfied"]) - } -} - -func TestRunIdentityOmitsBackendSpecificProvenanceForContainerRunSummary(t *testing.T) { - s := newBrokerAPIServiceForTests(t, APIConfig{}) - const runID = "run-container-identity" - _ = putRunScopedArtifactForLocalOpsTest(t, s, runID, "step-1") - recordContainerIdentityRuntimeFacts(t, s, runID) - - run := fetchSingleRunSummary(t, s, "req-run-container-identity") - assertContainerSummaryIdentityFields(t, run) - assertSummaryOmitsBackendSpecificProvenance(t, run) -} - -func TestRunSummaryKeepsAuditPostureDistinctFromBackendAndRuntimePosture(t *testing.T) { - s := newBrokerAPIServiceForTests(t, APIConfig{}) - const runID = "run-posture-separation" - _ = putRunScopedArtifactForLocalOpsTest(t, s, runID, "step-1") - if err := s.RecordRuntimeFacts(runID, launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: launcherbackend.BackendLaunchReceipt{ - RunID: runID, - StageID: "artifact_flow", - RoleInstanceID: "workspace-1", - RoleFamily: "workspace", - BackendKind: launcherbackend.BackendKindContainer, - IsolationAssuranceLevel: launcherbackend.IsolationAssuranceDegraded, - ProvisioningPosture: launcherbackend.ProvisioningPostureAttested, - }}); err != nil { - t.Fatalf("RecordRuntimeFacts returned error: %v", err) - } - s.auditLedger = nil - - run := fetchSingleRunSummary(t, s, "req-run-posture-separation") - if run.BackendKind != launcherbackend.BackendKindContainer || run.IsolationAssuranceLevel != launcherbackend.IsolationAssuranceDegraded || !run.RuntimePostureDegraded { - t.Fatalf("runtime posture projection changed unexpectedly: %+v", run) - } - if !run.AuditCurrentlyDegraded || run.AuditIntegrityStatus != "degraded" || run.AuditAnchoringStatus != "degraded" { - t.Fatalf("audit posture should degrade independently when verification unavailable: %+v", run) - } -} - -func recordBackendPosturePolicyDecisionForRun(t *testing.T, s *Service, runID, manifestHash, actionHash, instanceID string) string { - t.Helper() - decision := policyengine.PolicyDecision{ - SchemaID: "runecode.protocol.v0.PolicyDecision", - SchemaVersion: "0.3.0", - DecisionOutcome: policyengine.DecisionDeny, - PolicyReasonCode: "deny_by_default", - ManifestHash: manifestHash, - PolicyInputHashes: []string{"sha256:" + strings.Repeat("2", 64)}, - ActionRequestHash: actionHash, - RelevantArtifactHashes: []string{"sha256:" + strings.Repeat("4", 64)}, - DetailsSchemaID: "runecode.protocol.details.policy.evaluation.v0", - Details: map[string]any{"precedence": "approval_profile_moderate", "instance_id": instanceID}, - } - if err := s.RecordPolicyDecision(runID, "", decision); err != nil { - t.Fatalf("RecordPolicyDecision returned error: %v", err) - } - refs := s.PolicyDecisionRefsForRun(runID) - if len(refs) == 0 { - t.Fatal("PolicyDecisionRefsForRun returned empty refs") - } - return refs[0] -} - -func recordBackendPostureApprovalForRun(t *testing.T, s *Service, runID, selectorRunID, policyRef, manifestHash, actionHash, requestDigest, decisionDigest, instanceID string) string { - t.Helper() - approvalID := "sha256:" + strings.Repeat("a", 64) - now := time.Now().UTC().Round(0) - if err := s.RecordApproval(artifacts.ApprovalRecord{ - ApprovalID: approvalID, - Status: "consumed", - WorkspaceID: workspaceIDForRun(runID), - InstanceID: instanceID, - RunID: selectorRunID, - ActionKind: policyengine.ActionKindBackendPosture, - RequestedAt: now.Add(-2 * time.Minute), - DecidedAt: func() *time.Time { t := now.Add(-1 * time.Minute); return &t }(), - ConsumedAt: func() *time.Time { t := now; return &t }(), - ApprovalTriggerCode: "reduced_assurance_backend", - ChangesIfApproved: "Reduced-assurance backend posture change may be applied.", - ApprovalAssuranceLevel: "reauthenticated", - PresenceMode: "hardware_touch", - PolicyDecisionHash: policyRef, - ManifestHash: manifestHash, - ActionRequestHash: actionHash, - RequestDigest: requestDigest, - DecisionDigest: decisionDigest, - }); err != nil { - t.Fatalf("RecordApproval returned error: %v", err) - } - return approvalID -} - -func recordContainerRuntimeFactsForBackendEvidence(t *testing.T, s *Service, runID string) { - t.Helper() - if err := s.RecordRuntimeFacts(runID, launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: launcherbackend.BackendLaunchReceipt{ - RunID: runID, - StageID: "artifact_flow", - RoleInstanceID: "workspace-1", - RoleFamily: "workspace", - BackendKind: launcherbackend.BackendKindContainer, - IsolationAssuranceLevel: launcherbackend.IsolationAssuranceDegraded, - ProvisioningPosture: launcherbackend.ProvisioningPostureAttested, - }}); err != nil { - t.Fatalf("RecordRuntimeFacts returned error: %v", err) - } -} - -func backendPostureSelectionEvidenceForState(t *testing.T, state map[string]any) map[string]any { - t.Helper() - evidence, ok := state["backend_posture_selection_evidence"].(map[string]any) - if !ok { - t.Fatalf("authoritative_state.backend_posture_selection_evidence = %T, want map", state["backend_posture_selection_evidence"]) - } - return evidence -} - -func backendPosturePolicyRefsFromEvidence(t *testing.T, evidence map[string]any) []string { - t.Helper() - if refs, ok := evidence["policy_decision_refs"].([]string); ok { - return refs - } - refsAny, ok := evidence["policy_decision_refs"].([]any) - if !ok { - t.Fatalf("backend_posture_selection_evidence.policy_decision_refs = %T, want []string", evidence["policy_decision_refs"]) - } - refs := make([]string, 0, len(refsAny)) - for _, item := range refsAny { - value, ok := item.(string) - if !ok { - t.Fatalf("policy_decision_refs entry = %T, want string", item) - } - refs = append(refs, value) - } - return refs -} - -func backendPostureApprovalEvidenceFromEvidence(t *testing.T, evidence map[string]any) map[string]any { - t.Helper() - approvalEvidence, ok := evidence["approval"].(map[string]any) - if !ok { - t.Fatalf("backend_posture_selection_evidence.approval = %T, want map", evidence["approval"]) - } - return approvalEvidence -} - -func containsStringInSlice(values []string, target string) bool { - for _, value := range values { - if value == target { - return true - } - } - return false -} - -func assertBackendPostureApprovalEvidence(t *testing.T, approvalEvidence map[string]any, approvalID, requestDigest, decisionDigest, policyRef string) { - t.Helper() - if approvalEvidence["approval_id"] != approvalID { - t.Fatalf("backend_posture_selection_evidence.approval.approval_id = %v, want %q", approvalEvidence["approval_id"], approvalID) - } - if approvalEvidence["approval_request_digest"] != requestDigest { - t.Fatalf("backend_posture_selection_evidence.approval.approval_request_digest = %v, want %q", approvalEvidence["approval_request_digest"], requestDigest) - } - if approvalEvidence["approval_decision_digest"] != decisionDigest { - t.Fatalf("backend_posture_selection_evidence.approval.approval_decision_digest = %v, want %q", approvalEvidence["approval_decision_digest"], decisionDigest) - } - if approvalEvidence["policy_decision_hash"] != policyRef { - t.Fatalf("backend_posture_selection_evidence.approval.policy_decision_hash = %v, want %q", approvalEvidence["policy_decision_hash"], policyRef) - } - if approvalEvidence["status"] != "consumed" { - t.Fatalf("backend_posture_selection_evidence.approval.status = %v, want consumed", approvalEvidence["status"]) - } -} - -func recordContainerIdentityRuntimeFacts(t *testing.T, s *Service, runID string) { - t.Helper() - if err := s.RecordRuntimeFacts(runID, launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: launcherbackend.BackendLaunchReceipt{ - RunID: runID, - StageID: "artifact_flow", - RoleInstanceID: "workspace-1", - RoleFamily: "workspace", - BackendKind: launcherbackend.BackendKindContainer, - IsolationAssuranceLevel: launcherbackend.IsolationAssuranceDegraded, - ProvisioningPosture: launcherbackend.ProvisioningPostureAttested, - HypervisorImplementation: launcherbackend.HypervisorImplementationNotApplicable, - AccelerationKind: launcherbackend.AccelerationKindNotApplicable, - TransportKind: launcherbackend.TransportKindNotApplicable, - QEMUProvenance: &launcherbackend.QEMUProvenance{Version: "9.1.0", BuildIdentity: "qemu-system-x86_64"}, - RuntimeImageDescriptorDigest: "sha256:" + strings.Repeat("d", 64), - }}); err != nil { - t.Fatalf("RecordRuntimeFacts returned error: %v", err) - } -} - -func fetchSingleRunSummary(t *testing.T, s *Service, requestID string) RunSummary { - t.Helper() - runList, errResp := s.HandleRunList(context.Background(), RunListRequest{SchemaID: "runecode.protocol.v0.RunListRequest", SchemaVersion: "0.1.0", RequestID: requestID, Limit: 10}, RequestContext{}) - if errResp != nil { - t.Fatalf("HandleRunList error response: %+v", errResp) - } - if len(runList.Runs) != 1 { - t.Fatalf("run count = %d, want 1", len(runList.Runs)) - } - return runList.Runs[0] -} - -func assertContainerSummaryIdentityFields(t *testing.T, run RunSummary) { - t.Helper() - if run.BackendKind != launcherbackend.BackendKindContainer { - t.Fatalf("summary.backend_kind = %q, want %q", run.BackendKind, launcherbackend.BackendKindContainer) - } - if run.IsolationAssuranceLevel != launcherbackend.IsolationAssuranceDegraded { - t.Fatalf("summary.isolation_assurance_level = %q, want %q", run.IsolationAssuranceLevel, launcherbackend.IsolationAssuranceDegraded) - } - if run.ProvisioningPosture != launcherbackend.ProvisioningPostureAttested { - t.Fatalf("summary.provisioning_posture = %q, want %q", run.ProvisioningPosture, launcherbackend.ProvisioningPostureAttested) - } -} - -func assertSummaryOmitsBackendSpecificProvenance(t *testing.T, run RunSummary) { - t.Helper() - payload, err := json.Marshal(run) - if err != nil { - t.Fatalf("json.Marshal returned error: %v", err) - } - serialized := string(payload) - for _, forbidden := range []string{"qemu_provenance", "hypervisor_implementation", "transport_kind", "runtime_image_descriptor_digest"} { - if strings.Contains(serialized, forbidden) { - t.Fatalf("run summary identity contains backend-specific provenance field %q: %s", forbidden, serialized) - } - } -} diff --git a/internal/brokerapi/local_api_ops_runtime_facts_container_test.go b/internal/brokerapi/local_api_ops_runtime_facts_container_test.go index 4acbd3d0..af0e16f7 100644 --- a/internal/brokerapi/local_api_ops_runtime_facts_container_test.go +++ b/internal/brokerapi/local_api_ops_runtime_facts_container_test.go @@ -1,6 +1,7 @@ package brokerapi import ( + "path/filepath" "slices" "strings" "testing" @@ -20,6 +21,46 @@ func TestRunDetailRuntimeFactsContainerProjectionUsesNotApplicablePostureVocabul assertContainerAuthoritativePostureVocabulary(t, runGet.Run.AuthoritativeState) } +func TestRunDetailRuntimeFactsContainerProjectionSurvivesServiceRestartWithPersistedEvidence(t *testing.T) { + root := t.TempDir() + storeRoot := filepath.Join(root, "store") + ledgerRoot := filepath.Join(root, "ledger") + svc, err := NewServiceWithConfig(storeRoot, ledgerRoot, APIConfig{RepositoryRoot: repositoryRootForProjectSubstrateTests(t)}) + if err != nil { + t.Fatalf("NewServiceWithConfig returned error: %v", err) + } + const runID = "run-container-runtime-restart" + putRunScopedArtifactForLocalOpsTest(t, svc, runID, "step-1") + facts := containerRuntimeFactsFixtureWithPostHandshakeEvidence(runID) + if err := svc.RecordRuntimeFacts(runID, facts); err != nil { + t.Fatalf("RecordRuntimeFacts returned error: %v", err) + } + preRestartEvidence := svc.RuntimeEvidence(runID) + if preRestartEvidence.Attestation == nil || preRestartEvidence.AttestationVerification == nil { + t.Fatalf("pre-restart runtime evidence attestation/verification missing: %#v", preRestartEvidence) + } + + reloaded, err := NewServiceWithConfig(storeRoot, ledgerRoot, APIConfig{RepositoryRoot: repositoryRootForProjectSubstrateTests(t)}) + if err != nil { + t.Fatalf("NewServiceWithConfig(reload) returned error: %v", err) + } + runGet := mustRunGetForRuntimeFactsRestartTest(t, reloaded, runID) + state := runGet.Run.AuthoritativeState + if state["backend_kind"] != launcherbackend.BackendKindContainer { + t.Fatalf("authoritative_state.backend_kind = %v, want %q", state["backend_kind"], launcherbackend.BackendKindContainer) + } + if state["attestation_evidence_digest"] != preRestartEvidence.Attestation.EvidenceDigest { + t.Fatalf("authoritative_state.attestation_evidence_digest = %v, want %q after restart", state["attestation_evidence_digest"], preRestartEvidence.Attestation.EvidenceDigest) + } + if state["attestation_verification_digest"] != preRestartEvidence.AttestationVerification.VerificationDigest { + t.Fatalf("authoritative_state.attestation_verification_digest = %v, want %q after restart", state["attestation_verification_digest"], preRestartEvidence.AttestationVerification.VerificationDigest) + } + wantVerificationSucceeded := preRestartEvidence.Attestation != nil && preRestartEvidence.AttestationVerification != nil && preRestartEvidence.AttestationVerification.VerificationDigest != "" && preRestartEvidence.AttestationVerification.VerificationResult == launcherbackend.AttestationVerificationResultValid && preRestartEvidence.AttestationVerification.ReplayVerdict == launcherbackend.AttestationReplayVerdictOriginal + if state["attestation_verification_succeeded"] != wantVerificationSucceeded { + t.Fatalf("authoritative_state.attestation_verification_succeeded = %v, want %v from persisted verification", state["attestation_verification_succeeded"], wantVerificationSucceeded) + } +} + func TestRunDetailRuntimeFactsContainerProjectionFailsClosedForNonWorkspaceRoleAndWeakNetworking(t *testing.T) { s := newBrokerAPIServiceForTests(t, APIConfig{}) const runID = "run-container-role-scope" @@ -71,36 +112,31 @@ func containerRuntimeFactsFixtureForPostureVocab(runID string) launcherbackend.R func containerAttestedLaunchReceipt(runID, roleInstanceID, roleFamily string) launcherbackend.BackendLaunchReceipt { componentDigests := map[string]string{"image": "sha256:" + strings.Repeat("e", 64)} return launcherbackend.BackendLaunchReceipt{ - RunID: runID, - StageID: "artifact_flow", - RoleInstanceID: roleInstanceID, - RoleFamily: roleFamily, - BackendKind: launcherbackend.BackendKindContainer, - IsolationAssuranceLevel: launcherbackend.IsolationAssuranceDegraded, - ProvisioningPosture: launcherbackend.ProvisioningPostureAttested, - IsolateID: "isolate-container-1", - SessionID: "session-container-1", - SessionNonce: "nonce-container-0123456789abcdef", - LaunchContextDigest: "sha256:" + strings.Repeat("a", 64), - HandshakeTranscriptHash: "sha256:" + strings.Repeat("b", 64), - IsolateSessionKeyIDValue: strings.Repeat("c", 64), - RuntimeImageDescriptorDigest: "sha256:" + strings.Repeat("d", 64), - RuntimeImageBootProfile: launcherbackend.BootProfileContainerOCIImageV1, - BootComponentDigestByName: componentDigests, - BootComponentDigests: []string{"sha256:" + strings.Repeat("e", 64)}, - AuthorityStateDigest: "sha256:" + strings.Repeat("f", 64), - AuthorityStateRevision: 1, - AttestationEvidenceSourceKind: launcherbackend.AttestationSourceKindTrustedRuntime, - AttestationMeasurementProfile: launcherbackend.MeasurementProfileContainerImageV1, - AttestationFreshnessMaterial: []string{"session_nonce"}, - AttestationFreshnessBindingClaims: []string{"session_nonce", "handshake_transcript_hash", "launch_context_digest"}, - AttestationEvidenceClaimsDigest: containerRuntimeFactsMeasurementDigest(componentDigests), - AttestationVerifierPolicyID: "runtime_asset_admission_identity", - AttestationVerifierPolicyDigest: "sha256:" + strings.Repeat("f", 64), - AttestationVerificationRulesVersion: "trusted-runtime-v1", - AttestationVerificationTimestamp: "2026-04-09T09:59:00Z", - AttestationVerificationResult: launcherbackend.AttestationVerificationResultValid, - AttestationReplayVerdict: launcherbackend.AttestationReplayVerdictOriginal, + RunID: runID, + StageID: "artifact_flow", + RoleInstanceID: roleInstanceID, + RoleFamily: roleFamily, + BackendKind: launcherbackend.BackendKindContainer, + IsolationAssuranceLevel: launcherbackend.IsolationAssuranceDegraded, + ProvisioningPosture: launcherbackend.ProvisioningPostureAttested, + IsolateID: "isolate-container-1", + SessionID: "session-container-1", + SessionNonce: "nonce-container-0123456789abcdef", + LaunchContextDigest: "sha256:" + strings.Repeat("a", 64), + HandshakeTranscriptHash: "sha256:" + strings.Repeat("b", 64), + IsolateSessionKeyIDValue: strings.Repeat("c", 64), + SessionSecurity: &launcherbackend.SessionSecurityPosture{MutuallyAuthenticated: true, Encrypted: true, ProofOfPossessionVerified: true, ReplayProtected: true}, + RuntimeImageDescriptorDigest: "sha256:" + strings.Repeat("d", 64), + RuntimeImageBootProfile: launcherbackend.BootProfileContainerOCIImageV1, + BootComponentDigestByName: componentDigests, + BootComponentDigests: []string{"sha256:" + strings.Repeat("e", 64)}, + AuthorityStateDigest: "sha256:" + strings.Repeat("f", 64), + AuthorityStateRevision: 1, + AttestationEvidenceSourceKind: launcherbackend.AttestationSourceKindTrustedRuntime, + AttestationMeasurementProfile: launcherbackend.MeasurementProfileContainerImageV1, + AttestationFreshnessMaterial: []string{"session_nonce"}, + AttestationFreshnessBindingClaims: []string{"session_nonce", "handshake_transcript_hash", "launch_context_digest"}, + AttestationEvidenceClaimsDigest: containerRuntimeFactsMeasurementDigest(componentDigests), } } @@ -143,13 +179,63 @@ func containerRuntimeFactsFixtureForRoleScope(runID string) launcherbackend.Runt } } +func containerRuntimeFactsFixtureWithPostHandshakeEvidence(runID string) launcherbackend.RuntimeFactsSnapshot { + receipt := containerAttestedLaunchReceipt(runID, "workspace-1", "workspace") + return launcherbackend.RuntimeFactsSnapshot{ + LaunchReceipt: receipt, + PostHandshakeAttestationInput: containerRuntimeFactsPostHandshakeAttestationInput(receipt), + HardeningPosture: launcherbackend.AppliedHardeningPosture{ + Requested: launcherbackend.HardeningRequestedHardened, + Effective: launcherbackend.HardeningEffectiveHardened, + ExecutionIdentityPosture: launcherbackend.HardeningExecutionIdentityUnprivileged, + RootlessPosture: launcherbackend.HardeningRootlessBestEffort, + FilesystemExposurePosture: launcherbackend.HardeningFilesystemExposureRestricted, + WritableLayersPosture: launcherbackend.HardeningWritableLayersEphemeral, + NetworkExposurePosture: launcherbackend.HardeningNetworkExposureNone, + NetworkNamespacePosture: launcherbackend.HardeningNetworkNamespacePerRole, + NetworkDefaultPosture: launcherbackend.HardeningNetworkDefaultLoopbackOnly, + EgressEnforcementPosture: launcherbackend.HardeningEgressEnforcementHostLevel, + SyscallFilteringPosture: launcherbackend.HardeningSyscallFilteringSeccomp, + CapabilitiesPosture: launcherbackend.HardeningCapabilitiesDropped, + DeviceSurfacePosture: launcherbackend.HardeningDeviceSurfaceAllowlist, + ControlChannelKind: launcherbackend.TransportKindNotApplicable, + AccelerationKind: launcherbackend.AccelerationKindNotApplicable, + BackendEvidenceRefs: []string{"container-hardening:mvp-v0"}, + }, + } +} + +func containerRuntimeFactsPostHandshakeAttestationInput(receipt launcherbackend.BackendLaunchReceipt) *launcherbackend.PostHandshakeRuntimeAttestationInput { + return &launcherbackend.PostHandshakeRuntimeAttestationInput{ + RunID: receipt.RunID, + IsolateID: receipt.IsolateID, + SessionID: receipt.SessionID, + SessionNonce: receipt.SessionNonce, + RuntimeEvidenceCollected: true, + LaunchContextDigest: receipt.LaunchContextDigest, + HandshakeTranscriptHash: receipt.HandshakeTranscriptHash, + IsolateSessionKeyIDValue: receipt.IsolateSessionKeyIDValue, + RuntimeImageDescriptorDigest: receipt.RuntimeImageDescriptorDigest, + RuntimeImageBootProfile: receipt.RuntimeImageBootProfile, + RuntimeImageVerifierRef: receipt.RuntimeImageVerifierRef, + AuthorityStateDigest: receipt.AuthorityStateDigest, + BootComponentDigestByName: map[string]string{"image": receipt.BootComponentDigestByName["image"]}, + BootComponentDigests: append([]string{}, receipt.BootComponentDigests...), + AttestationSourceKind: receipt.AttestationEvidenceSourceKind, + MeasurementProfile: receipt.AttestationMeasurementProfile, + FreshnessMaterial: append([]string{}, receipt.AttestationFreshnessMaterial...), + FreshnessBindingClaims: append([]string{}, receipt.AttestationFreshnessBindingClaims...), + EvidenceClaimsDigest: receipt.AttestationEvidenceClaimsDigest, + } +} + func assertContainerSummaryPostureVocabulary(t *testing.T, summary RunSummary) { t.Helper() if summary.BackendKind != launcherbackend.BackendKindContainer { t.Fatalf("summary.backend_kind = %q, want %q", summary.BackendKind, launcherbackend.BackendKindContainer) } - if summary.ProvisioningPosture != launcherbackend.ProvisioningPostureAttested { - t.Fatalf("summary.provisioning_posture = %q, want %q", summary.ProvisioningPosture, launcherbackend.ProvisioningPostureAttested) + if summary.ProvisioningPosture != launcherbackend.ProvisioningPostureTOFU { + t.Fatalf("summary.provisioning_posture = %q, want %q", summary.ProvisioningPosture, launcherbackend.ProvisioningPostureTOFU) } if !summary.RuntimePostureDegraded { t.Fatal("summary.runtime_posture_degraded = false, want true for container reduced assurance") @@ -161,8 +247,8 @@ func assertContainerAuthoritativePostureVocabulary(t *testing.T, state map[strin if state["runtime_posture_degraded"] != true { t.Fatalf("authoritative_state.runtime_posture_degraded = %v, want true", state["runtime_posture_degraded"]) } - if state["provisioning_posture"] != launcherbackend.ProvisioningPostureAttested { - t.Fatalf("authoritative_state.provisioning_posture = %v, want %q", state["provisioning_posture"], launcherbackend.ProvisioningPostureAttested) + if state["provisioning_posture"] != launcherbackend.ProvisioningPostureTOFU { + t.Fatalf("authoritative_state.provisioning_posture = %v, want %q", state["provisioning_posture"], launcherbackend.ProvisioningPostureTOFU) } if state["transport_kind"] != launcherbackend.TransportKindNotApplicable { t.Fatalf("authoritative_state.transport_kind = %v, want %q", state["transport_kind"], launcherbackend.TransportKindNotApplicable) diff --git a/internal/brokerapi/local_api_ops_runtime_facts_fixture_test.go b/internal/brokerapi/local_api_ops_runtime_facts_fixture_test.go new file mode 100644 index 00000000..8807da52 --- /dev/null +++ b/internal/brokerapi/local_api_ops_runtime_facts_fixture_test.go @@ -0,0 +1,157 @@ +package brokerapi + +import ( + "strings" + + "github.com/runecode-ai/runecode/internal/launcherbackend" +) + +func launcherRuntimeFactsFixture() launcherbackend.RuntimeFactsSnapshot { + receipt := launcherRuntimeFactsReceiptFixture() + return launcherbackend.RuntimeFactsSnapshot{ + LaunchReceipt: receipt, + PostHandshakeAttestationInput: runtimeFactsPostHandshakeAttestationInput(receipt), + HardeningPosture: launcherbackend.AppliedHardeningPosture{ + Requested: "hardened", + Effective: "degraded", + DegradedReasons: []string{"seccomp_unavailable"}, + AccelerationKind: "kvm", + BackendEvidenceRefs: []string{"qemu-provenance:sha256:" + strings.Repeat("9", 64)}, + }, + TerminalReport: &launcherbackend.BackendTerminalReport{ + TerminationKind: launcherbackend.BackendTerminationKindFailed, + FailureReasonCode: launcherbackend.BackendErrorCodeWatchdogTimeout, + FailClosed: true, + FallbackPosture: launcherbackend.BackendFallbackPostureNoAutomaticFallback, + TerminatedAt: "2026-04-09T10:00:00Z", + }, + } +} + +func runtimeFactsPostHandshakeAttestationInput(receipt launcherbackend.BackendLaunchReceipt) *launcherbackend.PostHandshakeRuntimeAttestationInput { + return &launcherbackend.PostHandshakeRuntimeAttestationInput{ + RunID: receipt.RunID, + IsolateID: receipt.IsolateID, + SessionID: receipt.SessionID, + SessionNonce: receipt.SessionNonce, + RuntimeEvidenceCollected: true, + LaunchContextDigest: receipt.LaunchContextDigest, + HandshakeTranscriptHash: receipt.HandshakeTranscriptHash, + IsolateSessionKeyIDValue: receipt.IsolateSessionKeyIDValue, + RuntimeImageDescriptorDigest: receipt.RuntimeImageDescriptorDigest, + RuntimeImageBootProfile: receipt.RuntimeImageBootProfile, + RuntimeImageVerifierRef: receipt.RuntimeImageVerifierRef, + AuthorityStateDigest: receipt.AuthorityStateDigest, + BootComponentDigestByName: map[string]string{ + "kernel": receipt.BootComponentDigestByName["kernel"], + "initrd": receipt.BootComponentDigestByName["initrd"], + }, + BootComponentDigests: append([]string{}, receipt.BootComponentDigests...), + AttestationSourceKind: receipt.AttestationEvidenceSourceKind, + MeasurementProfile: receipt.AttestationMeasurementProfile, + FreshnessMaterial: append([]string{}, receipt.AttestationFreshnessMaterial...), + FreshnessBindingClaims: append([]string{}, receipt.AttestationFreshnessBindingClaims...), + EvidenceClaimsDigest: receipt.AttestationEvidenceClaimsDigest, + VerifierPolicyID: receipt.AttestationVerifierPolicyID, + VerifierPolicyDigest: receipt.AttestationVerifierPolicyDigest, + VerificationResult: receipt.AttestationVerificationResult, + ReplayVerdict: receipt.AttestationReplayVerdict, + } +} + +func launcherRuntimeFactsReceiptFixture() launcherbackend.BackendLaunchReceipt { + receipt := runtimeFactsMicroVMReceiptIdentity() + receipt.ResourceLimits = &launcherbackend.BackendResourceLimits{VCPUCount: 2, MemoryMiB: 512, DiskMiB: 4096, LaunchTimeoutSeconds: 60, BindTimeoutSeconds: 30, ActiveTimeoutSeconds: 600, TerminationGraceSeconds: 15} + receipt.WatchdogPolicy = &launcherbackend.BackendWatchdogPolicy{Enabled: true, TerminateOnMisbehavior: true, HeartbeatTimeoutSeconds: 30, NoProgressTimeoutSeconds: 120} + receipt.Lifecycle = &launcherbackend.BackendLifecycleSnapshot{CurrentState: launcherbackend.BackendLifecycleStateActive, PreviousState: launcherbackend.BackendLifecycleStateBinding, TerminateBetweenSteps: true, TransitionCount: 4} + receipt.CachePosture = &launcherbackend.BackendCachePosture{WarmPoolEnabled: true, BootCacheEnabled: true, ResetOrDestroyBeforeReuse: true, ReusePriorSessionIdentityKeys: false, DigestPinned: true, SignaturePinned: true} + receipt.CacheEvidence = &launcherbackend.BackendCacheEvidence{ImageCacheResult: launcherbackend.CacheResultHit, BootArtifactCacheResult: launcherbackend.CacheResultMiss, ResolvedImageDescriptorDigest: "sha256:" + strings.Repeat("a", 64), ResolvedBootComponentDigests: []string{"sha256:" + strings.Repeat("b", 64), "sha256:" + strings.Repeat("c", 64)}} + receipt.AttachmentPlanSummary = runtimeFactsAttachmentPlanSummaryFixture() + receipt.WorkspaceEncryptionPosture = runtimeFactsWorkspaceEncryptionPostureFixture() + receipt.LaunchFailureReasonCode = launcherbackend.BackendErrorCodeAccelerationUnavailable + applyRuntimeFactsAttestation(&receipt) + return receipt +} + +func runtimeFactsMicroVMReceiptIdentity() launcherbackend.BackendLaunchReceipt { + return launcherbackend.BackendLaunchReceipt{ + RunID: "run-launcher-facts", + StageID: "artifact_flow", + RoleInstanceID: "workspace-1", + RoleFamily: "workspace", + RoleKind: "workspace-edit", + BackendKind: launcherbackend.BackendKindMicroVM, + IsolationAssuranceLevel: launcherbackend.IsolationAssuranceIsolated, + ProvisioningPosture: launcherbackend.ProvisioningPostureAttested, + IsolateID: "isolate-1", + SessionID: "session-1", + SessionNonce: "nonce-0123456789abcdef", + LaunchContextDigest: "sha256:" + strings.Repeat("d", 64), + HandshakeTranscriptHash: "sha256:" + strings.Repeat("e", 64), + IsolateSessionKeyIDValue: strings.Repeat("f", 64), + HostingNodeID: "node-1", + SessionSecurity: runtimeFactsSessionSecurityFixture(), + HypervisorImplementation: launcherbackend.HypervisorImplementationQEMU, + AccelerationKind: launcherbackend.AccelerationKindKVM, + TransportKind: launcherbackend.TransportKindVSock, + QEMUProvenance: &launcherbackend.QEMUProvenance{Version: "9.1.0", BuildIdentity: "qemu-system-x86_64 (runecode)"}, + RuntimeImageDescriptorDigest: "sha256:" + strings.Repeat("a", 64), + RuntimeImageBootProfile: launcherbackend.BootProfileMicroVMLinuxKernelInitrdV1, + RuntimeImageSignerRef: "signer:trusted-ci", + RuntimeImageSignatureDigest: "sha256:" + strings.Repeat("9", 64), + AuthorityStateDigest: "sha256:" + strings.Repeat("8", 64), + AuthorityStateRevision: 1, + BootComponentDigestByName: runtimeFactsBootComponentDigestsByNameFixture(), + BootComponentDigests: []string{"sha256:" + strings.Repeat("b", 64), "sha256:" + strings.Repeat("c", 64)}, + } +} + +func applyRuntimeFactsAttestation(receipt *launcherbackend.BackendLaunchReceipt) { + if receipt == nil { + return + } + receipt.AttestationEvidenceSourceKind = launcherbackend.AttestationSourceKindTrustedRuntime + receipt.AttestationMeasurementProfile = launcherbackend.MeasurementProfileMicroVMBootV1 + receipt.AttestationFreshnessMaterial = []string{"session_nonce"} + receipt.AttestationFreshnessBindingClaims = []string{"session_nonce", "handshake_transcript_hash", "launch_context_digest"} + receipt.AttestationEvidenceClaimsDigest = runtimeFactsMeasurementDigests(*receipt)[0] + receipt.AttestationVerifierPolicyID = "runtime_asset_admission_identity" + receipt.AttestationVerifierPolicyDigest = receipt.AuthorityStateDigest + receipt.AttestationVerificationResult = launcherbackend.AttestationVerificationResultValid + receipt.AttestationReplayVerdict = launcherbackend.AttestationReplayVerdictOriginal +} + +func runtimeFactsMeasurementDigests(receipt launcherbackend.BackendLaunchReceipt) []string { + digests, err := launcherbackend.DeriveExpectedMeasurementDigests(receipt.AttestationMeasurementProfile, receipt.RuntimeImageBootProfile, receipt.BootComponentDigestByName) + if err != nil { + panic(err) + } + return digests +} + +func runtimeFactsSessionSecurityFixture() *launcherbackend.SessionSecurityPosture { + return &launcherbackend.SessionSecurityPosture{MutuallyAuthenticated: true, Encrypted: true, ProofOfPossessionVerified: true, ReplayProtected: true, FrameFormat: launcherbackend.SessionFramingLengthPrefixedV1, MaxFrameBytes: 4096, MaxHandshakeMessageBytes: 2048} +} + +func runtimeFactsBootComponentDigestsByNameFixture() map[string]string { + return map[string]string{ + "kernel": "sha256:" + strings.Repeat("b", 64), + "initrd": "sha256:" + strings.Repeat("c", 64), + } +} + +func runtimeFactsAttachmentPlanSummaryFixture() *launcherbackend.AttachmentPlanSummary { + return &launcherbackend.AttachmentPlanSummary{ + Roles: []launcherbackend.AttachmentRoleSummary{ + {Role: launcherbackend.AttachmentRoleLaunchContext, ReadOnly: true, ChannelKind: launcherbackend.AttachmentChannelReadOnlyVolume, DigestCount: 1}, + {Role: launcherbackend.AttachmentRoleWorkspace, ReadOnly: false, ChannelKind: launcherbackend.AttachmentChannelWritableVolume}, + {Role: launcherbackend.AttachmentRoleInputArtifacts, ReadOnly: true, ChannelKind: launcherbackend.AttachmentChannelArtifactImage, DigestCount: 2}, + {Role: launcherbackend.AttachmentRoleScratch, ReadOnly: false, ChannelKind: launcherbackend.AttachmentChannelEphemeralVolume}, + }, + Constraints: launcherbackend.AttachmentRealizationConstraints{NoHostFilesystemMounts: true, HostLocalPathsVisible: false, DeviceNumberingVisible: false, GuestMountAsContractIdentity: false}, + } +} + +func runtimeFactsWorkspaceEncryptionPostureFixture() *launcherbackend.WorkspaceEncryptionPosture { + return &launcherbackend.WorkspaceEncryptionPosture{Required: true, AtRestProtection: launcherbackend.WorkspaceAtRestProtectionHostManagedEncryption, KeyProtectionPosture: launcherbackend.WorkspaceKeyProtectionHardwareBacked, Effective: true, EvidenceRefs: []string{"workspace-encryption:host-managed"}} +} diff --git a/internal/brokerapi/local_api_ops_runtime_facts_test.go b/internal/brokerapi/local_api_ops_runtime_facts_test.go index e028decf..def4fd97 100644 --- a/internal/brokerapi/local_api_ops_runtime_facts_test.go +++ b/internal/brokerapi/local_api_ops_runtime_facts_test.go @@ -23,6 +23,10 @@ func TestRunDetailRuntimeFactsProjectionSurvivesServiceRestart(t *testing.T) { if err := svc.RecordRuntimeFacts("run-runtime-restart", facts); err != nil { t.Fatalf("RecordRuntimeFacts returned error: %v", err) } + preRestartEvidence := svc.RuntimeEvidence("run-runtime-restart") + if preRestartEvidence.Attestation == nil || preRestartEvidence.AttestationVerification == nil { + t.Fatalf("pre-restart runtime evidence attestation/verification missing: %#v", preRestartEvidence) + } reloaded, err := NewServiceWithConfig(storeRoot, ledgerRoot, APIConfig{RepositoryRoot: repositoryRootForProjectSubstrateTests(t)}) if err != nil { @@ -38,6 +42,12 @@ func TestRunDetailRuntimeFactsProjectionSurvivesServiceRestart(t *testing.T) { if runGet.Run.AuthoritativeState["session_id"] != "session-1" { t.Fatalf("authoritative_state.session_id = %v, want session-1 after restart", runGet.Run.AuthoritativeState["session_id"]) } + if runGet.Run.AuthoritativeState["attestation_evidence_digest"] != preRestartEvidence.Attestation.EvidenceDigest { + t.Fatalf("authoritative_state.attestation_evidence_digest = %v, want %q after restart", runGet.Run.AuthoritativeState["attestation_evidence_digest"], preRestartEvidence.Attestation.EvidenceDigest) + } + if runGet.Run.AuthoritativeState["attestation_verification_digest"] != preRestartEvidence.AttestationVerification.VerificationDigest { + t.Fatalf("authoritative_state.attestation_verification_digest = %v, want %q after restart", runGet.Run.AuthoritativeState["attestation_verification_digest"], preRestartEvidence.AttestationVerification.VerificationDigest) + } } func TestRunSummaryAndDetailProjectRecordedLauncherRuntimeFacts(t *testing.T) { @@ -52,11 +62,16 @@ func TestRunSummaryAndDetailProjectRecordedLauncherRuntimeFacts(t *testing.T) { runGet := mustRunGetForRuntimeFactsTest(t, s) state := runGet.Run.AuthoritativeState + evidence := s.RuntimeEvidence("run-launcher-facts") + if evidence.Attestation == nil || evidence.AttestationVerification == nil { + t.Fatalf("runtime evidence attestation/verification missing: %#v", evidence) + } assertRuntimeFactsIdentityProjection(t, state) assertRuntimeFactsImageProjection(t, state) assertRuntimeFactsBackendEvidenceProjection(t, state) assertRuntimeFactsRuntimePolicyProjection(t, state) assertRuntimeFactsSessionAndHardeningProjection(t, state) + assertRuntimeFactsAttestationReferenceProjection(t, state, evidence) assertRuntimeFactsAttachmentProjection(t, state) assertRuntimeFactsTerminalProjection(t, state) } @@ -70,119 +85,12 @@ func TestRecordRuntimeFactsRejectsMismatchedEmbeddedRunID(t *testing.T) { } } -func launcherRuntimeFactsFixture() launcherbackend.RuntimeFactsSnapshot { - return launcherbackend.RuntimeFactsSnapshot{ - LaunchReceipt: launcherRuntimeFactsReceiptFixture(), - HardeningPosture: launcherbackend.AppliedHardeningPosture{Requested: "hardened", Effective: "degraded", DegradedReasons: []string{"seccomp_unavailable"}, AccelerationKind: "kvm", BackendEvidenceRefs: []string{"qemu-provenance:sha256:" + strings.Repeat("9", 64)}}, - TerminalReport: &launcherbackend.BackendTerminalReport{TerminationKind: launcherbackend.BackendTerminationKindFailed, FailureReasonCode: launcherbackend.BackendErrorCodeWatchdogTimeout, FailClosed: true, FallbackPosture: launcherbackend.BackendFallbackPostureNoAutomaticFallback, TerminatedAt: "2026-04-09T10:00:00Z"}, - } -} - func launcherRuntimeFactsFixtureForRun(runID string) launcherbackend.RuntimeFactsSnapshot { facts := launcherRuntimeFactsFixture() facts.LaunchReceipt.RunID = runID return facts } -func launcherRuntimeFactsReceiptFixture() launcherbackend.BackendLaunchReceipt { - receipt := runtimeFactsMicroVMReceiptIdentity() - receipt.ResourceLimits = &launcherbackend.BackendResourceLimits{VCPUCount: 2, MemoryMiB: 512, DiskMiB: 4096, LaunchTimeoutSeconds: 60, BindTimeoutSeconds: 30, ActiveTimeoutSeconds: 600, TerminationGraceSeconds: 15} - receipt.WatchdogPolicy = &launcherbackend.BackendWatchdogPolicy{Enabled: true, TerminateOnMisbehavior: true, HeartbeatTimeoutSeconds: 30, NoProgressTimeoutSeconds: 120} - receipt.Lifecycle = &launcherbackend.BackendLifecycleSnapshot{CurrentState: launcherbackend.BackendLifecycleStateActive, PreviousState: launcherbackend.BackendLifecycleStateBinding, TerminateBetweenSteps: true, TransitionCount: 4} - receipt.CachePosture = &launcherbackend.BackendCachePosture{WarmPoolEnabled: true, BootCacheEnabled: true, ResetOrDestroyBeforeReuse: true, ReusePriorSessionIdentityKeys: false, DigestPinned: true, SignaturePinned: true} - receipt.CacheEvidence = &launcherbackend.BackendCacheEvidence{ImageCacheResult: launcherbackend.CacheResultHit, BootArtifactCacheResult: launcherbackend.CacheResultMiss, ResolvedImageDescriptorDigest: "sha256:" + strings.Repeat("a", 64), ResolvedBootComponentDigests: []string{"sha256:" + strings.Repeat("b", 64), "sha256:" + strings.Repeat("c", 64)}} - receipt.AttachmentPlanSummary = runtimeFactsAttachmentPlanSummaryFixture() - receipt.WorkspaceEncryptionPosture = runtimeFactsWorkspaceEncryptionPostureFixture() - receipt.LaunchFailureReasonCode = launcherbackend.BackendErrorCodeAccelerationUnavailable - applyRuntimeFactsAttestation(&receipt) - return receipt -} - -func runtimeFactsMicroVMReceiptIdentity() launcherbackend.BackendLaunchReceipt { - return launcherbackend.BackendLaunchReceipt{ - RunID: "run-launcher-facts", - StageID: "artifact_flow", - RoleInstanceID: "workspace-1", - RoleFamily: "workspace", - RoleKind: "workspace-edit", - BackendKind: launcherbackend.BackendKindMicroVM, - IsolationAssuranceLevel: launcherbackend.IsolationAssuranceIsolated, - ProvisioningPosture: launcherbackend.ProvisioningPostureAttested, - IsolateID: "isolate-1", - SessionID: "session-1", - SessionNonce: "nonce-0123456789abcdef", - LaunchContextDigest: "sha256:" + strings.Repeat("d", 64), - HandshakeTranscriptHash: "sha256:" + strings.Repeat("e", 64), - IsolateSessionKeyIDValue: strings.Repeat("f", 64), - HostingNodeID: "node-1", - SessionSecurity: runtimeFactsSessionSecurityFixture(), - HypervisorImplementation: launcherbackend.HypervisorImplementationQEMU, - AccelerationKind: launcherbackend.AccelerationKindKVM, - TransportKind: launcherbackend.TransportKindVSock, - QEMUProvenance: &launcherbackend.QEMUProvenance{Version: "9.1.0", BuildIdentity: "qemu-system-x86_64 (runecode)"}, - RuntimeImageDescriptorDigest: "sha256:" + strings.Repeat("a", 64), - RuntimeImageBootProfile: launcherbackend.BootProfileMicroVMLinuxKernelInitrdV1, - RuntimeImageSignerRef: "signer:trusted-ci", - RuntimeImageSignatureDigest: "sha256:" + strings.Repeat("9", 64), - AuthorityStateDigest: "sha256:" + strings.Repeat("8", 64), - AuthorityStateRevision: 1, - BootComponentDigestByName: runtimeFactsBootComponentDigestsByNameFixture(), - BootComponentDigests: []string{"sha256:" + strings.Repeat("b", 64), "sha256:" + strings.Repeat("c", 64)}, - } -} - -func applyRuntimeFactsAttestation(receipt *launcherbackend.BackendLaunchReceipt) { - if receipt == nil { - return - } - receipt.AttestationEvidenceSourceKind = launcherbackend.AttestationSourceKindTrustedRuntime - receipt.AttestationMeasurementProfile = launcherbackend.MeasurementProfileMicroVMBootV1 - receipt.AttestationFreshnessMaterial = []string{"session_nonce"} - receipt.AttestationFreshnessBindingClaims = []string{"session_nonce", "handshake_transcript_hash", "launch_context_digest"} - receipt.AttestationEvidenceClaimsDigest = runtimeFactsMeasurementDigests(*receipt)[0] - receipt.AttestationVerifierPolicyID = "runtime_asset_admission_identity" - receipt.AttestationVerifierPolicyDigest = "sha256:" + strings.Repeat("8", 64) - receipt.AttestationVerificationRulesVersion = "trusted-runtime-v1" - receipt.AttestationVerificationTimestamp = "2026-04-09T09:59:00Z" - receipt.AttestationVerificationResult = launcherbackend.AttestationVerificationResultValid - receipt.AttestationReplayVerdict = launcherbackend.AttestationReplayVerdictOriginal -} - -func runtimeFactsMeasurementDigests(receipt launcherbackend.BackendLaunchReceipt) []string { - digests, err := launcherbackend.DeriveExpectedMeasurementDigests(receipt.AttestationMeasurementProfile, receipt.RuntimeImageBootProfile, receipt.BootComponentDigestByName) - if err != nil { - panic(err) - } - return digests -} - -func runtimeFactsSessionSecurityFixture() *launcherbackend.SessionSecurityPosture { - return &launcherbackend.SessionSecurityPosture{MutuallyAuthenticated: true, Encrypted: true, ProofOfPossessionVerified: true, ReplayProtected: true, FrameFormat: launcherbackend.SessionFramingLengthPrefixedV1, MaxFrameBytes: 4096, MaxHandshakeMessageBytes: 2048} -} - -func runtimeFactsBootComponentDigestsByNameFixture() map[string]string { - return map[string]string{ - "kernel": "sha256:" + strings.Repeat("b", 64), - "initrd": "sha256:" + strings.Repeat("c", 64), - } -} - -func runtimeFactsAttachmentPlanSummaryFixture() *launcherbackend.AttachmentPlanSummary { - return &launcherbackend.AttachmentPlanSummary{ - Roles: []launcherbackend.AttachmentRoleSummary{ - {Role: launcherbackend.AttachmentRoleLaunchContext, ReadOnly: true, ChannelKind: launcherbackend.AttachmentChannelReadOnlyVolume, DigestCount: 1}, - {Role: launcherbackend.AttachmentRoleWorkspace, ReadOnly: false, ChannelKind: launcherbackend.AttachmentChannelWritableVolume}, - {Role: launcherbackend.AttachmentRoleInputArtifacts, ReadOnly: true, ChannelKind: launcherbackend.AttachmentChannelArtifactImage, DigestCount: 2}, - {Role: launcherbackend.AttachmentRoleScratch, ReadOnly: false, ChannelKind: launcherbackend.AttachmentChannelEphemeralVolume}, - }, - Constraints: launcherbackend.AttachmentRealizationConstraints{NoHostFilesystemMounts: true, HostLocalPathsVisible: false, DeviceNumberingVisible: false, GuestMountAsContractIdentity: false}, - } -} - -func runtimeFactsWorkspaceEncryptionPostureFixture() *launcherbackend.WorkspaceEncryptionPosture { - return &launcherbackend.WorkspaceEncryptionPosture{Required: true, AtRestProtection: launcherbackend.WorkspaceAtRestProtectionHostManagedEncryption, KeyProtectionPosture: launcherbackend.WorkspaceKeyProtectionHardwareBacked, Effective: true, EvidenceRefs: []string{"workspace-encryption:host-managed"}} -} - func mustRunListForRuntimeFactsTest(t *testing.T, service *Service) RunListResponse { t.Helper() response, errResp := service.HandleRunList(context.Background(), RunListRequest{SchemaID: "runecode.protocol.v0.RunListRequest", SchemaVersion: "0.1.0", RequestID: "req-run-list-runtime-facts", Limit: 10}, RequestContext{}) @@ -387,6 +295,34 @@ func assertRuntimeFactsSessionAndHardeningProjection(t *testing.T, state map[str assertRuntimeFactsHardeningProjection(t, state, hardening) } +func assertRuntimeFactsAttestationReferenceProjection(t *testing.T, state map[string]any, evidence launcherbackend.RuntimeEvidenceSnapshot) { + t.Helper() + if state["attestation_evidence_digest"] != evidence.Attestation.EvidenceDigest { + t.Fatalf("authoritative_state.attestation_evidence_digest = %v, want %q", state["attestation_evidence_digest"], evidence.Attestation.EvidenceDigest) + } + if state["attestation_verification_attestation_evidence_digest"] != evidence.AttestationVerification.AttestationEvidenceDigest { + t.Fatalf("authoritative_state.attestation_verification_attestation_evidence_digest = %v, want %q", state["attestation_verification_attestation_evidence_digest"], evidence.AttestationVerification.AttestationEvidenceDigest) + } + if state["attestation_verification_digest"] != evidence.AttestationVerification.VerificationDigest { + t.Fatalf("authoritative_state.attestation_verification_digest = %v, want %q", state["attestation_verification_digest"], evidence.AttestationVerification.VerificationDigest) + } + if state["attestation_replay_identity_digest"] != evidence.AttestationVerification.ReplayIdentityDigest { + t.Fatalf("authoritative_state.attestation_replay_identity_digest = %v, want %q", state["attestation_replay_identity_digest"], evidence.AttestationVerification.ReplayIdentityDigest) + } + if _, ok := state["attestation_verifier_policy_id"]; ok { + t.Fatalf("authoritative_state.attestation_verifier_policy_id should be omitted: %v", state["attestation_verifier_policy_id"]) + } + if _, ok := state["attestation_verifier_policy_digest"]; ok { + t.Fatalf("authoritative_state.attestation_verifier_policy_digest should be omitted: %v", state["attestation_verifier_policy_digest"]) + } + if state["attestation_verification_attestation_evidence_digest"] != evidence.AttestationVerification.AttestationEvidenceDigest { + t.Fatalf("authoritative_state.attestation_verification_attestation_evidence_digest = %v, want %q", state["attestation_verification_attestation_evidence_digest"], evidence.AttestationVerification.AttestationEvidenceDigest) + } + if state["attestation_verification_rules_profile_version"] != evidence.AttestationVerification.VerificationRulesProfileVersion { + t.Fatalf("authoritative_state.attestation_verification_rules_profile_version = %v, want %q", state["attestation_verification_rules_profile_version"], evidence.AttestationVerification.VerificationRulesProfileVersion) + } +} + func assertRuntimeFactsHardeningProjection(t *testing.T, state map[string]any, hardening map[string]any) { t.Helper() if hardening["degraded"] != true { diff --git a/internal/brokerapi/local_api_run_detail_ops.go b/internal/brokerapi/local_api_run_detail_ops.go index fc03004c..11a15d09 100644 --- a/internal/brokerapi/local_api_run_detail_ops.go +++ b/internal/brokerapi/local_api_run_detail_ops.go @@ -86,10 +86,14 @@ func buildRunDetail(summary RunSummary, verification AuditVerificationSurface, a } func buildRunStageSummary(summary RunSummary, artifactsForRun []artifacts.ArtifactRecord, pendingIDs []string) RunStageSummary { + stageID := strings.TrimSpace(summary.CurrentStageID) + if stageID == "" { + stageID = "artifact_flow" + } return RunStageSummary{ SchemaID: "runecode.protocol.v0.RunStageSummary", SchemaVersion: "0.1.0", - StageID: "artifact_flow", + StageID: stageID, LifecycleState: summary.LifecycleState, StartedAt: summary.StartedAt, FinishedAt: summary.FinishedAt, diff --git a/internal/brokerapi/local_api_run_detail_state_authoritative_ops.go b/internal/brokerapi/local_api_run_detail_state_authoritative_ops.go index f4dd851c..f882032e 100644 --- a/internal/brokerapi/local_api_run_detail_state_authoritative_ops.go +++ b/internal/brokerapi/local_api_run_detail_state_authoritative_ops.go @@ -101,15 +101,55 @@ func projectReceiptIdentityState(state map[string]any, receipt launcherbackend.B } func projectAttestationIdentityState(state map[string]any, evidence launcherbackend.RuntimeEvidenceSnapshot) { - attestationPosture, attestationReasons := launcherbackend.DeriveAttestationPostureFromEvidence(evidence) + attestationPosture, _ := launcherbackend.DeriveAttestationPostureFromEvidence(evidence) attestationVerifierClass := launcherbackend.DeriveAttestationVerifierClassFromEvidence(evidence) state["attestation_posture"] = attestationPosture state["attestation_verifier_class"] = attestationVerifierClass - state["session_binding_present"] = evidence.Session != nil && strings.TrimSpace(evidence.Session.EvidenceDigest) != "" - state["attestation_evidence_present"] = evidence.Attestation != nil && strings.TrimSpace(evidence.Attestation.EvidenceDigest) != "" - state["attestation_verification_succeeded"] = evidence.AttestationVerification != nil && evidence.AttestationVerification.VerificationResult == launcherbackend.AttestationVerificationResultValid && evidence.AttestationVerification.ReplayVerdict == launcherbackend.AttestationReplayVerdictOriginal - if len(attestationReasons) > 0 { - state["attestation_reason_codes"] = attestationReasons + projectAttestationPresenceState(state, evidence) + projectAttestationDigestState(state, evidence) + projectAttestationVerificationMetadataState(state, evidence) +} + +func projectAttestationPresenceState(state map[string]any, evidence launcherbackend.RuntimeEvidenceSnapshot) { + sessionBindingPresent := evidence.Session != nil && strings.TrimSpace(evidence.Session.EvidenceDigest) != "" + attestationEvidencePresent := evidence.Attestation != nil && strings.TrimSpace(evidence.Attestation.EvidenceDigest) != "" + attestationVerificationPresent := evidence.AttestationVerification != nil + attestationVerificationSucceeded := attestationVerificationPresent && attestationEvidencePresent && strings.TrimSpace(evidence.AttestationVerification.VerificationDigest) != "" && evidence.AttestationVerification.VerificationResult == launcherbackend.AttestationVerificationResultValid && evidence.AttestationVerification.ReplayVerdict == launcherbackend.AttestationReplayVerdictOriginal + state["session_binding_present"] = sessionBindingPresent + state["attestation_evidence_present"] = attestationEvidencePresent + state["attestation_verification_succeeded"] = attestationVerificationSucceeded + state["attestation_verification_failed"] = attestationVerificationPresent && !attestationVerificationSucceeded +} + +func projectAttestationDigestState(state map[string]any, evidence launcherbackend.RuntimeEvidenceSnapshot) { + if evidence.Attestation != nil { + if digest := strings.TrimSpace(evidence.Attestation.EvidenceDigest); digest != "" { + state["attestation_evidence_digest"] = digest + } + } + if evidence.AttestationVerification == nil { + return + } + if digest := strings.TrimSpace(evidence.AttestationVerification.AttestationEvidenceDigest); digest != "" { + state["attestation_verification_attestation_evidence_digest"] = digest + if _, ok := state["attestation_evidence_digest"]; !ok { + state["attestation_evidence_digest"] = digest + } + } + if digest := strings.TrimSpace(evidence.AttestationVerification.VerificationDigest); digest != "" { + state["attestation_verification_digest"] = digest + } + if digest := strings.TrimSpace(evidence.AttestationVerification.ReplayIdentityDigest); digest != "" { + state["attestation_replay_identity_digest"] = digest + } +} + +func projectAttestationVerificationMetadataState(state map[string]any, evidence launcherbackend.RuntimeEvidenceSnapshot) { + if evidence.AttestationVerification == nil { + return + } + if profile := strings.TrimSpace(evidence.AttestationVerification.VerificationRulesProfileVersion); profile != "" { + state["attestation_verification_rules_profile_version"] = profile } } @@ -169,4 +209,7 @@ func projectWorkflowDerivedState(state map[string]any, summary RunSummary, manif if summary.WorkflowKind != "" { state["workflow_kind"] = summary.WorkflowKind } + if summary.WorkflowKind == "" && summary.CurrentStageID == "" { + state["workflow_projection_reason"] = "missing_active_run_plan_authority" + } } diff --git a/internal/brokerapi/local_api_run_detail_state_authoritative_ops_test.go b/internal/brokerapi/local_api_run_detail_state_authoritative_ops_test.go new file mode 100644 index 00000000..39eaab8c --- /dev/null +++ b/internal/brokerapi/local_api_run_detail_state_authoritative_ops_test.go @@ -0,0 +1,82 @@ +package brokerapi + +import ( + "strings" + "testing" + + "github.com/runecode-ai/runecode/internal/launcherbackend" +) + +func TestProjectAttestationIdentityStateTracksSessionAndEvidencePresence(t *testing.T) { + baseEvidence := attestationIdentityBaseEvidence() + + state := map[string]any{} + projectAttestationIdentityState(state, baseEvidence) + assertAttestationSignals(t, state, true, false, false, false) + + withAttestation := baseEvidence + withAttestation.Attestation = &launcherbackend.IsolateAttestationEvidence{EvidenceDigest: "sha256:" + strings.Repeat("2", 64)} + state = map[string]any{} + projectAttestationIdentityState(state, withAttestation) + assertAttestationSignals(t, state, true, true, false, false) +} + +func TestProjectAttestationIdentityStateRequiresVerificationDigestForSuccess(t *testing.T) { + withAttestation := attestationIdentityBaseEvidence() + withAttestation.Attestation = &launcherbackend.IsolateAttestationEvidence{EvidenceDigest: "sha256:" + strings.Repeat("2", 64)} + + withValidVerification := withAttestation + withValidVerification.AttestationVerification = &launcherbackend.IsolateAttestationVerificationRecord{ + VerificationResult: launcherbackend.AttestationVerificationResultValid, + ReplayVerdict: launcherbackend.AttestationReplayVerdictOriginal, + VerificationDigest: "sha256:" + strings.Repeat("3", 64), + } + state := map[string]any{} + projectAttestationIdentityState(state, withValidVerification) + assertAttestationSignals(t, state, true, true, true, false) + + withUndigestedVerification := withAttestation + withUndigestedVerification.AttestationVerification = &launcherbackend.IsolateAttestationVerificationRecord{ + VerificationResult: launcherbackend.AttestationVerificationResultValid, + ReplayVerdict: launcherbackend.AttestationReplayVerdictOriginal, + } + state = map[string]any{} + projectAttestationIdentityState(state, withUndigestedVerification) + assertAttestationSignals(t, state, true, true, false, true) +} + +func TestProjectAttestationIdentityStateMarksInvalidVerificationAsFailed(t *testing.T) { + withAttestation := attestationIdentityBaseEvidence() + withAttestation.Attestation = &launcherbackend.IsolateAttestationEvidence{EvidenceDigest: "sha256:" + strings.Repeat("2", 64)} + withAttestation.AttestationVerification = &launcherbackend.IsolateAttestationVerificationRecord{ + VerificationResult: launcherbackend.AttestationVerificationResultInvalid, + ReplayVerdict: launcherbackend.AttestationReplayVerdictUnknown, + } + + state := map[string]any{} + projectAttestationIdentityState(state, withAttestation) + assertAttestationSignals(t, state, true, true, false, true) +} + +func attestationIdentityBaseEvidence() launcherbackend.RuntimeEvidenceSnapshot { + return launcherbackend.RuntimeEvidenceSnapshot{ + Launch: launcherbackend.LaunchRuntimeEvidence{ProvisioningPosture: launcherbackend.ProvisioningPostureAttested}, + Session: &launcherbackend.SessionRuntimeEvidence{EvidenceDigest: "sha256:" + strings.Repeat("1", 64)}, + } +} + +func assertAttestationSignals(t *testing.T, state map[string]any, wantSessionBinding, wantEvidence, wantVerificationSucceeded, wantVerificationFailed bool) { + t.Helper() + if got, _ := state["session_binding_present"].(bool); got != wantSessionBinding { + t.Fatalf("session_binding_present = %v, want %v", got, wantSessionBinding) + } + if got, _ := state["attestation_evidence_present"].(bool); got != wantEvidence { + t.Fatalf("attestation_evidence_present = %v, want %v", got, wantEvidence) + } + if got, _ := state["attestation_verification_succeeded"].(bool); got != wantVerificationSucceeded { + t.Fatalf("attestation_verification_succeeded = %v, want %v", got, wantVerificationSucceeded) + } + if got, _ := state["attestation_verification_failed"].(bool); got != wantVerificationFailed { + t.Fatalf("attestation_verification_failed = %v, want %v", got, wantVerificationFailed) + } +} diff --git a/internal/brokerapi/local_api_run_summary_authority_test.go b/internal/brokerapi/local_api_run_summary_authority_test.go new file mode 100644 index 00000000..7872721d --- /dev/null +++ b/internal/brokerapi/local_api_run_summary_authority_test.go @@ -0,0 +1,61 @@ +package brokerapi + +import ( + "context" + "strings" + "testing" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/runplan" +) + +func TestRunSummaryUsesBuiltInWorkflowAuthorityForSessionExecutionPath(t *testing.T) { + s, runID, entry := compileRunSummaryAuthorityFixture(t) + + runGet, errResp := s.HandleRunGet(context.Background(), RunGetRequest{ + SchemaID: "runecode.protocol.v0.RunGetRequest", + SchemaVersion: "0.1.0", + RequestID: "req-run-summary-authority", + RunID: runID, + }, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleRunGet error response: %+v", errResp) + } + if got := strings.TrimSpace(runGet.Run.Summary.WorkflowKind); got != strings.TrimSpace(entry.WorkflowID) { + t.Fatalf("workflow_kind = %q, want %q", got, entry.WorkflowID) + } + if got := strings.TrimSpace(runGet.Run.Summary.WorkflowDefinitionHash); got != strings.TrimSpace(entry.WorkflowDefinitionHash) { + t.Fatalf("workflow_definition_hash = %q, want %q", got, entry.WorkflowDefinitionHash) + } +} + +func compileRunSummaryAuthorityFixture(t *testing.T) (*Service, string, runplan.BuiltInWorkflowCatalogEntry) { + t.Helper() + s := newBrokerAPIServiceForTests(t, APIConfig{}) + runID := "run-summary-authority" + entry, err := builtInCatalogEntryForWorkflowOperation(sessionWorkflowOperationApprovedImplementation) + if err != nil { + t.Fatalf("builtInCatalogEntryForWorkflowOperation returned error: %v", err) + } + workflowPayload, processPayload, err := builtInWorkflowAssetPayloads(entry.WorkflowID) + if err != nil { + t.Fatalf("builtInWorkflowAssetPayloads returned error: %v", err) + } + workflowRef, processRef, err := s.persistSessionExecutionWorkflowAssets(runID, workflowPayload, processPayload) + if err != nil { + t.Fatalf("persistSessionExecutionWorkflowAssets returned error: %v", err) + } + if err := s.SetRunStatus(runID, "starting"); err != nil { + t.Fatalf("SetRunStatus returned error: %v", err) + } + if _, err := s.CompileAndPersistRunPlan(CompileAndPersistRunPlanRequest{ + RunID: runID, + PlanID: "plan-run-summary-authority", + WorkflowDefinitionRef: workflowRef.Digest, + ProcessDefinitionRef: processRef.Digest, + PolicyContextHash: artifacts.DigestBytes([]byte("run-summary-authority")), + }); err != nil { + t.Fatalf("CompileAndPersistRunPlan returned error: %v", err) + } + return s, runID, entry +} diff --git a/internal/brokerapi/local_api_run_summary_ops.go b/internal/brokerapi/local_api_run_summary_ops.go index 1f879bbe..65fc59cd 100644 --- a/internal/brokerapi/local_api_run_summary_ops.go +++ b/internal/brokerapi/local_api_run_summary_ops.go @@ -1,7 +1,6 @@ package brokerapi import ( - "fmt" "sort" "strings" "time" @@ -20,7 +19,7 @@ func (s *Service) runSummaries(order string) ([]RunSummary, error) { summaries := make([]RunSummary, 0, len(byRun)) for runID, records := range byRun { runnerAdvisory, _ := s.RunnerAdvisory(runID) - summaries = append(summaries, buildRunSummary(runID, projectContextIdentity, records, runStatus[runID], pendingByRun[runID], verification, s.RuntimeFacts(runID), runnerAdvisory)) + summaries = append(summaries, s.buildRunSummary(runID, projectContextIdentity, records, runStatus[runID], pendingByRun[runID], verification, s.RuntimeFacts(runID), runnerAdvisory)) } sortRunSummaries(summaries, order) return summaries, nil @@ -71,35 +70,21 @@ func buildRunRecordIndex(all []artifacts.ArtifactRecord, runStatus map[string]st return byRun } -func buildRunSummary(runID string, projectContextIdentityDigest string, records []artifacts.ArtifactRecord, status string, pending int, verification AuditVerificationSurface, runtimeFacts launcherbackend.RuntimeFactsSnapshot, runnerAdvisory artifacts.RunnerAdvisoryState) RunSummary { +func (s *Service) buildRunSummary(runID string, projectContextIdentityDigest string, records []artifacts.ArtifactRecord, status string, pending int, verification AuditVerificationSurface, runtimeFacts launcherbackend.RuntimeFactsSnapshot, runnerAdvisory artifacts.RunnerAdvisoryState) RunSummary { created, updated := runRecordTiming(records) state := runLifecycleFromStore(status, pending, len(records) > 0, runnerAdvisory, runtimeFacts) - workflowKind, workflowDefinitionHash := inferWorkflowIdentity(records) - backendKind, isolationAssuranceLevel, provisioningPosture := normalizedRunSummaryPosture(runtimeFacts) - summary := RunSummary{ - SchemaID: "runecode.protocol.v0.RunSummary", - SchemaVersion: "0.2.0", - RunID: runID, - WorkspaceID: workspaceIDForProjectContext(projectContextIdentityDigest), - ProjectContextIdentity: strings.TrimSpace(projectContextIdentityDigest), - WorkflowKind: workflowKind, - WorkflowDefinitionHash: workflowDefinitionHash, - CreatedAt: created.UTC().Format(time.RFC3339), - StartedAt: created.UTC().Format(time.RFC3339), - UpdatedAt: updated.UTC().Format(time.RFC3339), - LifecycleState: state, - CurrentStageID: currentStageIDFromArtifacts(records, pending), - PendingApprovalCount: pending, - ApprovalProfile: "unknown", - BackendKind: backendKind, - IsolationAssuranceLevel: isolationAssuranceLevel, - ProvisioningPosture: provisioningPosture, - RuntimePostureDegraded: runtimePostureDegraded(backendKind, isolationAssuranceLevel), - AssuranceLevel: isolationAssuranceLevel, - AuditIntegrityStatus: verification.Summary.IntegrityStatus, - AuditAnchoringStatus: verification.Summary.AnchoringStatus, - AuditCurrentlyDegraded: verification.Summary.CurrentlyDegraded, - } + projection := s.resolveRunSummaryProjection(runID, records, pending) + summary := newRunSummary( + runID, + projectContextIdentityDigest, + created, + updated, + state, + pending, + projection, + verification, + runtimeFacts, + ) finalizeRunSummaryTerminalState(&summary, state, updated) return summary } @@ -168,84 +153,3 @@ func pendingApprovalCountByRun(approvals []ApprovalSummary) map[string]int { } return counts } - -func workspaceIDForRun(runID string) string { - trimmed := strings.TrimSpace(runID) - if trimmed == "" { - return "workspace-local" - } - return "workspace-" + trimmed -} - -func workspaceIDForProjectContext(projectContextIdentityDigest string) string { - identity := strings.TrimSpace(projectContextIdentityDigest) - if identity == "" { - return "workspace-local" - } - return "workspace-" + strings.TrimPrefix(identity, "sha256:") -} - -func stageIDForRun(runID string) string { - if strings.TrimSpace(runID) == "" { - return "artifact_flow" - } - return "artifact_flow" -} - -func currentStageIDFromArtifacts(records []artifacts.ArtifactRecord, pending int) string { - if len(records) == 0 && pending == 0 { - return "" - } - return "artifact_flow" -} - -func inferWorkflowIdentity(records []artifacts.ArtifactRecord) (string, string) { - workflowKind := inferWorkflowKind(records) - workflowDefinitionHash := "" - manifestDigests := uniqueSortedDigests(runProvenanceDigests(records)) - if len(manifestDigests) == 1 { - workflowDefinitionHash = manifestDigests[0] - } - return workflowKind, workflowDefinitionHash -} - -func runProvenanceDigests(records []artifacts.ArtifactRecord) []string { - out := make([]string, 0, len(records)) - for _, record := range records { - out = append(out, record.Reference.ProvenanceReceiptHash) - } - return out -} - -func inferWorkflowKind(records []artifacts.ArtifactRecord) string { - hasDiff := false - hasBuildLogs := false - hasUnapproved := false - for _, record := range records { - switch record.Reference.DataClass { - case artifacts.DataClassDiffs: - hasDiff = true - case artifacts.DataClassBuildLogs: - hasBuildLogs = true - case artifacts.DataClassUnapprovedFileExcerpts, artifacts.DataClassApprovedFileExcerpts: - hasUnapproved = true - } - } - switch { - case hasUnapproved: - return "excerpt_promotion" - case hasDiff && hasBuildLogs: - return "edit_build_gate" - case hasDiff: - return "edit_diff" - default: - return "" - } -} - -func runRoleInstanceID(role string) string { - if strings.TrimSpace(role) == "" { - return "role-unknown-1" - } - return fmt.Sprintf("%s-1", role) -} diff --git a/internal/brokerapi/local_api_run_summary_ops_test.go b/internal/brokerapi/local_api_run_summary_ops_test.go new file mode 100644 index 00000000..2cc9edf8 --- /dev/null +++ b/internal/brokerapi/local_api_run_summary_ops_test.go @@ -0,0 +1,182 @@ +package brokerapi + +import ( + "context" + "strings" + "testing" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/runplan" +) + +func TestRunSummaryUsesBuiltInWorkflowAuthorityForTypedDraftExecutionPath(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + exec, runGet := runGetForChangeDraftSummaryTest(t, s) + assertChangeDraftRunSummary(t, runGet) + assertRunHasTypedChangeDraftArtifact(t, s, exec.PrimaryRunID) +} + +func assertChangeDraftRunSummary(t *testing.T, runGet RunGetResponse) { + t.Helper() + if got := strings.TrimSpace(runGet.Run.Summary.WorkflowKind); got != "builtin_rc_change_draft_v0" { + t.Fatalf("workflow_kind = %q, want builtin_rc_change_draft_v0", got) + } + if runGet.Run.Summary.LifecycleState != "completed" { + t.Fatalf("lifecycle_state = %q, want completed", runGet.Run.Summary.LifecycleState) + } +} + +func assertRunHasTypedChangeDraftArtifact(t *testing.T, s *Service, runID string) { + t.Helper() + requireSessionExecutionLinkedArtifactByStepAndSchema(t, s, runID, "session_execution/change_draft_artifact", "runecode.protocol.v0.RuneContextChangeDraftArtifact", "") + if !runHasTypedChangeDraftArtifact(t, s, runID) { + t.Fatal("typed change draft artifact not found in run-scoped artifacts") + } +} + +func runHasTypedChangeDraftArtifact(t *testing.T, s *Service, runID string) bool { + t.Helper() + for _, record := range s.List() { + if record.RunID != runID || record.StepID != "session_execution/change_draft_artifact" { + continue + } + payload := mustArtifactPayload(t, s, record.Reference.Digest) + decoded := mustDecodeArtifactJSON(t, record.StepID, payload) + if strings.TrimSpace(stringValueFromMap(decoded, "schema_id")) == "runecode.protocol.v0.RuneContextChangeDraftArtifact" { + return true + } + } + return false +} + +func runGetForChangeDraftSummaryTest(t *testing.T, s *Service) (*SessionTurnExecution, RunGetResponse) { + t.Helper() + s.sessionExecutionRunner = launchSessionExecutionRunnerCompleteInProcessForTests + _ = mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-run-summary-draft-path", SessionID: "sess-run-summary-draft-path", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationChangeDraft}, UserMessageContentText: "summary path draft artifact"}) + getResp := mustSessionGet(t, s, "req-run-summary-draft-path-session", "sess-run-summary-draft-path") + if getResp.Session.LatestTurnExecution == nil { + t.Fatal("latest_turn_execution missing") + } + exec := getResp.Session.LatestTurnExecution + runGet, errResp := s.HandleRunGet(context.Background(), RunGetRequest{ + SchemaID: "runecode.protocol.v0.RunGetRequest", + SchemaVersion: "0.1.0", + RequestID: "req-run-summary-draft-path-get", + RunID: exec.PrimaryRunID, + }, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleRunGet error response: %+v", errResp) + } + return exec, runGet +} + +func TestRunSummaryUsesActivePlanAuthorityForApprovedImplementationPath(t *testing.T) { + repoRoot := t.TempDir() + writeProjectSubstrateAnchors(t, repoRoot, "0.1.0-alpha.14", "verified", "runecontext") + s := newBrokerAPIServiceForTests(t, APIConfig{RepositoryRoot: repoRoot}) + seedSessionRuntimeFactsForOpsTest(t, s, "run-summary-approved-impl", "sess-run-summary-approved-impl") + + mutationDigest := putApprovedImplementationMutationArtifactForTest(t, s, map[string]any{ + "target_path": "runecontext/changes/CHG-approved-impl/proposal.md", + "content": "# CHG-approved-impl\n\nImplemented by approved workflow.\n", + "content_digest": digestObject(artifacts.DigestBytes([]byte("# CHG-approved-impl\n\nImplemented by approved workflow.\n"))), + "write_mode": "create", + }) + approvedDigest := artifacts.DigestBytes([]byte("approved-run-summary-input")) + inputSetDigest := putApprovedImplementationInputSetForTest(t, s, approvedImplementationInputSetFixture(t, s, []string{approvedDigest, mutationDigest}, []string{mutationDigest}, nil)) + + _ = mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-run-summary-approved-impl", SessionID: "sess-run-summary-approved-impl", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationApprovedImplementation, BoundInputArtifacts: []SessionWorkflowPackBoundInputArtifact{{ArtifactRef: "implementation_input_set", ArtifactDigest: inputSetDigest}}}, UserMessageContentText: "apply approved implementation"}) + getResp := mustSessionGet(t, s, "req-run-summary-approved-impl-session", "sess-run-summary-approved-impl") + if getResp.Session.LatestTurnExecution == nil { + t.Fatal("latest_turn_execution missing") + } + exec := getResp.Session.LatestTurnExecution + runGet, errResp := s.HandleRunGet(context.Background(), RunGetRequest{SchemaID: "runecode.protocol.v0.RunGetRequest", SchemaVersion: "0.1.0", RequestID: "req-run-summary-approved-impl-get", RunID: exec.PrimaryRunID}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleRunGet error response: %+v", errResp) + } + if got := strings.TrimSpace(runGet.Run.Summary.WorkflowKind); got != "builtin_rc_approved_implementation_v0" { + t.Fatalf("workflow_kind = %q, want builtin_rc_approved_implementation_v0", got) + } + if got := strings.TrimSpace(runGet.Run.Summary.CurrentStageID); got == "" || got == "artifact_flow" { + t.Fatalf("current_stage_id = %q, want plan-authoritative non-artifact_flow stage", got) + } + if got := strings.TrimSpace(runGet.Run.Summary.ApprovalProfile); got != "moderate" { + t.Fatalf("approval_profile = %q, want moderate", got) + } + if runGet.Run.Summary.LifecycleState != "blocked" { + t.Fatalf("lifecycle_state = %q, want blocked from consumed approvals", runGet.Run.Summary.LifecycleState) + } +} + +func TestRunSummaryUsesPlanAuthoritativeStageAndApprovalProfileForDraftPromoteApply(t *testing.T) { + repoRoot := t.TempDir() + writeProjectSubstrateAnchors(t, repoRoot, "0.1.0-alpha.14", "verified", "runecontext") + s := newBrokerAPIServiceForTests(t, APIConfig{RepositoryRoot: repoRoot}) + seedSessionRuntimeFactsForOpsTest(t, s, "run-summary-promote", "sess-run-summary-promote") + + mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-run-summary-promote-draft", SessionID: "sess-run-summary-promote", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationChangeDraft}, UserMessageContentText: "summary promote draft"}) + draftGet := mustSessionGet(t, s, "req-run-summary-promote-draft-get", "sess-run-summary-promote") + if draftGet.Session.LatestTurnExecution == nil { + t.Fatal("latest_turn_execution missing after draft") + } + draftRunID := draftGet.Session.LatestTurnExecution.PrimaryRunID + draftDigest := digestForRunStep(t, s, draftRunID, "session_execution/change_draft_artifact") + + mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-run-summary-promote-apply", SessionID: "sess-run-summary-promote", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationDraftPromoteApply, BoundInputArtifacts: []SessionWorkflowPackBoundInputArtifact{{ArtifactRef: "change_draft_artifact", ArtifactDigest: draftDigest}}}, UserMessageContentText: "summary promote apply"}) + post := mustSessionGet(t, s, "req-run-summary-promote-apply-get", "sess-run-summary-promote") + if post.Session.LatestTurnExecution == nil { + t.Fatal("latest_turn_execution missing after promote/apply") + } + runID := post.Session.LatestTurnExecution.PrimaryRunID + runGet, errResp := s.HandleRunGet(context.Background(), RunGetRequest{SchemaID: "runecode.protocol.v0.RunGetRequest", SchemaVersion: "0.1.0", RequestID: "req-run-summary-promote-get", RunID: runID}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleRunGet error response: %+v", errResp) + } + if got := strings.TrimSpace(runGet.Run.Summary.WorkflowKind); got != "builtin_rc_draft_promote_v0" { + t.Fatalf("workflow_kind = %q, want builtin_rc_draft_promote_v0", got) + } + if got := strings.TrimSpace(runGet.Run.Summary.CurrentStageID); got == "" || got == "artifact_flow" { + t.Fatalf("current_stage_id = %q, want plan-authoritative non-artifact_flow stage", got) + } + if got := strings.TrimSpace(runGet.Run.Summary.ApprovalProfile); got != "moderate" { + t.Fatalf("approval_profile = %q, want moderate", got) + } +} + +func TestRunSummaryLeavesWorkflowIdentityUnknownWithoutActivePlanAuthority(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + runID := "run-summary-missing-authority" + entry := runplan.BuiltInWorkflowCatalogV0()[0] + if err := s.SetRunStatus(runID, "active"); err != nil { + t.Fatalf("SetRunStatus returned error: %v", err) + } + if _, err := s.Put(artifacts.PutRequest{ + Payload: []byte(`{"schema_id":"runecode.protocol.v0.WorkflowDefinition"}`), + ContentType: "application/json", + DataClass: artifacts.DataClassSpecText, + ProvenanceReceiptHash: entry.WorkflowDefinitionHash, + CreatedByRole: "brokerapi", + TrustedSource: true, + RunID: runID, + StepID: "session_execution/workflow_definition", + }); err != nil { + t.Fatalf("Put returned error: %v", err) + } + runGet, errResp := s.HandleRunGet(context.Background(), RunGetRequest{SchemaID: "runecode.protocol.v0.RunGetRequest", SchemaVersion: "0.1.0", RequestID: "req-run-summary-missing-authority", RunID: runID}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleRunGet error response: %+v", errResp) + } + if got := strings.TrimSpace(runGet.Run.Summary.WorkflowKind); got != "" { + t.Fatalf("workflow_kind = %q, want empty without active plan authority", got) + } + if got := strings.TrimSpace(runGet.Run.Summary.WorkflowDefinitionHash); got != "" { + t.Fatalf("workflow_definition_hash = %q, want empty without active plan authority", got) + } + if got := strings.TrimSpace(runGet.Run.Summary.CurrentStageID); got != "" { + t.Fatalf("current_stage_id = %q, want empty without active plan authority", got) + } + if got := runGet.Run.AuthoritativeState["workflow_projection_reason"]; got != "missing_active_run_plan_authority" { + t.Fatalf("authoritative_state.workflow_projection_reason = %v, want missing_active_run_plan_authority", got) + } +} diff --git a/internal/brokerapi/local_api_run_summary_projection_helpers.go b/internal/brokerapi/local_api_run_summary_projection_helpers.go new file mode 100644 index 00000000..1d177c13 --- /dev/null +++ b/internal/brokerapi/local_api_run_summary_projection_helpers.go @@ -0,0 +1,249 @@ +package brokerapi + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/launcherbackend" + "github.com/runecode-ai/runecode/internal/runplan" +) + +type runSummaryProjection struct { + workflowKind string + workflowDefinitionHash string + currentStageID string + approvalProfile string + projectionReason string +} + +type runSummaryPlanAuthoritativeProjection struct { + workflowKind string + workflowDefinitionHash string + currentStageID string + approvalProfile string + authoritative bool +} + +func (s *Service) resolveRunSummaryProjection(runID string, records []artifacts.ArtifactRecord, pending int) runSummaryProjection { + planProjection := s.runSummaryPlanProjection(runID) + workflowKind, workflowDefinitionHash, inferredByArtifacts := s.inferWorkflowIdentity(runID, records) + currentStageID := currentStageIDFromArtifacts(records, pending) + reason := "plan_authoritative" + if planProjection.authoritative { + workflowKind = coalesceTrimmed(planProjection.workflowKind, workflowKind) + workflowDefinitionHash = coalesceTrimmed(planProjection.workflowDefinitionHash, workflowDefinitionHash) + currentStageID = coalesceTrimmed(planProjection.currentStageID, currentStageID) + } else if inferredByArtifacts { + reason = "missing_active_run_plan_authority" + workflowKind = "" + workflowDefinitionHash = "" + currentStageID = "" + } else { + reason = "projection_unknown" + currentStageID = "" + } + + return runSummaryProjection{ + workflowKind: workflowKind, + workflowDefinitionHash: workflowDefinitionHash, + currentStageID: currentStageID, + approvalProfile: defaultTrimmed(planProjection.approvalProfile, "unknown"), + projectionReason: reason, + } +} + +func newRunSummary(runID, projectContextIdentityDigest string, created, updated time.Time, state string, pending int, projection runSummaryProjection, verification AuditVerificationSurface, runtimeFacts launcherbackend.RuntimeFactsSnapshot) RunSummary { + backendKind, isolationAssuranceLevel, provisioningPosture := normalizedRunSummaryPosture(runtimeFacts) + createdAt := created.UTC().Format(time.RFC3339) + updatedAt := updated.UTC().Format(time.RFC3339) + + return RunSummary{ + SchemaID: "runecode.protocol.v0.RunSummary", + SchemaVersion: "0.2.0", + RunID: runID, + WorkspaceID: workspaceIDForProjectContext(projectContextIdentityDigest), + ProjectContextIdentity: strings.TrimSpace(projectContextIdentityDigest), + WorkflowKind: projection.workflowKind, + WorkflowDefinitionHash: projection.workflowDefinitionHash, + CreatedAt: createdAt, + StartedAt: createdAt, + UpdatedAt: updatedAt, + LifecycleState: state, + CurrentStageID: projection.currentStageID, + PendingApprovalCount: pending, + ApprovalProfile: projection.approvalProfile, + BackendKind: backendKind, + IsolationAssuranceLevel: isolationAssuranceLevel, + ProvisioningPosture: provisioningPosture, + RuntimePostureDegraded: runtimePostureDegraded(backendKind, isolationAssuranceLevel), + AssuranceLevel: isolationAssuranceLevel, + AuditIntegrityStatus: verification.Summary.IntegrityStatus, + AuditAnchoringStatus: verification.Summary.AnchoringStatus, + AuditCurrentlyDegraded: verification.Summary.CurrentlyDegraded, + } +} + +func (s *Service) runSummaryPlanProjection(runID string) runSummaryPlanAuthoritativeProjection { + authority, ok, err := s.ActiveRunPlanAuthority(runID) + if err != nil || !ok { + return runSummaryPlanAuthoritativeProjection{} + } + projection := runSummaryPlanAuthoritativeProjection{ + workflowDefinitionHash: strings.TrimSpace(authority.WorkflowDefinitionHash), + authoritative: true, + } + if selectedEntry, err := selectSessionExecutionPlanEntry(authority.Entries); err == nil { + projection.currentStageID = strings.TrimSpace(selectedEntry.StageID) + } + if plan, err := s.decodeTrustedRunPlan(authority.RunPlanDigest); err == nil { + projection.approvalProfile = strings.TrimSpace(plan.ApprovalProfile) + if len(plan.Entries) > 0 { + projection.currentStageID = strings.TrimSpace(plan.Entries[len(plan.Entries)-1].StageID) + } + } + projection.workflowKind = workflowIDForWorkflowDefinitionHash(projection.workflowDefinitionHash) + return projection +} + +func (s *Service) decodeTrustedRunPlan(digest string) (runplan.RunPlan, error) { + payload, err := s.readArtifactPayload(strings.TrimSpace(digest)) + if err != nil { + return runplan.RunPlan{}, err + } + var planned runplan.RunPlan + if err := json.Unmarshal(payload, &planned); err != nil { + return runplan.RunPlan{}, err + } + return planned, nil +} + +func workspaceIDForRun(runID string) string { + trimmed := strings.TrimSpace(runID) + if trimmed == "" { + return "workspace-local" + } + return "workspace-" + trimmed +} + +func workspaceIDForProjectContext(projectContextIdentityDigest string) string { + identity := strings.TrimSpace(projectContextIdentityDigest) + if identity == "" { + return "workspace-local" + } + return "workspace-" + strings.TrimPrefix(identity, "sha256:") +} + +func stageIDForRun(runID string) string { + if strings.TrimSpace(runID) == "" { + return "artifact_flow" + } + return "artifact_flow" +} + +func currentStageIDFromArtifacts(records []artifacts.ArtifactRecord, pending int) string { + if len(records) == 0 && pending == 0 { + return "" + } + return "artifact_flow" +} + +func (s *Service) inferWorkflowIdentity(runID string, records []artifacts.ArtifactRecord) (string, string, bool) { + if authorityWorkflowID, workflowHash := s.inferWorkflowIdentityFromActivePlanAuthority(runID); authorityWorkflowID != "" || workflowHash != "" { + return authorityWorkflowID, workflowHash, false + } + for _, entry := range runplan.BuiltInWorkflowCatalogV0() { + if strings.TrimSpace(entry.WorkflowDefinitionHash) == "" { + continue + } + if runHasTrustedWorkflowDefinitionHash(runID, records, entry.WorkflowDefinitionHash) { + return strings.TrimSpace(entry.WorkflowID), strings.TrimSpace(entry.WorkflowDefinitionHash), true + } + } + workflowDefinitionHash := "" + manifestDigests := uniqueSortedDigests(runProvenanceDigests(records)) + if len(manifestDigests) == 1 { + workflowDefinitionHash = manifestDigests[0] + } + return "", workflowDefinitionHash, false +} + +func (s *Service) inferWorkflowIdentityFromActivePlanAuthority(runID string) (string, string) { + authority, ok, err := s.ActiveRunPlanAuthority(runID) + if err != nil || !ok { + return "", "" + } + workflowHash := strings.TrimSpace(authority.WorkflowDefinitionHash) + if workflowHash == "" { + return "", "" + } + if workflowID := workflowIDForWorkflowDefinitionHash(workflowHash); workflowID != "" { + return workflowID, workflowHash + } + return "", workflowHash +} + +func workflowIDForWorkflowDefinitionHash(workflowHash string) string { + workflowHash = strings.TrimSpace(workflowHash) + if workflowHash == "" { + return "" + } + for _, entry := range runplan.BuiltInWorkflowCatalogV0() { + if strings.TrimSpace(entry.WorkflowDefinitionHash) == workflowHash { + return strings.TrimSpace(entry.WorkflowID) + } + } + return "" +} + +func runHasTrustedWorkflowDefinitionHash(runID string, records []artifacts.ArtifactRecord, trustedHash string) bool { + trustedHash = strings.TrimSpace(trustedHash) + if trustedHash == "" { + return false + } + for _, record := range records { + if strings.TrimSpace(record.RunID) != strings.TrimSpace(runID) { + continue + } + if strings.TrimSpace(record.StepID) != "session_execution/workflow_definition" { + continue + } + if strings.TrimSpace(record.Reference.ProvenanceReceiptHash) == trustedHash { + return true + } + } + return false +} + +func runProvenanceDigests(records []artifacts.ArtifactRecord) []string { + out := make([]string, 0, len(records)) + for _, record := range records { + out = append(out, record.Reference.ProvenanceReceiptHash) + } + return out +} + +func runRoleInstanceID(role string) string { + if strings.TrimSpace(role) == "" { + return "role-unknown-1" + } + return fmt.Sprintf("%s-1", role) +} + +func coalesceTrimmed(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func defaultTrimmed(value, fallback string) string { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + return fallback +} diff --git a/internal/brokerapi/local_api_runner_gate_plan_compile_persist_test.go b/internal/brokerapi/local_api_runner_gate_plan_compile_persist_test.go index 7bf501bf..1e3ddabf 100644 --- a/internal/brokerapi/local_api_runner_gate_plan_compile_persist_test.go +++ b/internal/brokerapi/local_api_runner_gate_plan_compile_persist_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/runplan" ) func TestCompileAndPersistRunPlanBuildsDurableAuthorityAndCompilationBinding(t *testing.T) { @@ -91,6 +92,53 @@ func TestCompileAndPersistRunPlanDifferentPlanIDMissesCache(t *testing.T) { } } +func TestCompileAndPersistRunPlanApprovedInputSetSemanticDigestShapesCompileIdentity(t *testing.T) { + s := newTrustedRunPlanBrokerService(t) + runID := "run-compile-approved-input-semantic" + if err := s.SetRunStatus(runID, "active"); err != nil { + t.Fatalf("SetRunStatus returned error: %v", err) + } + workflowRef, processRef := putTrustedWorkflowAndProcessDefinitions(t, s, runID) + semanticDigest := "sha256:" + strings.Repeat("a", 64) + first, err := s.CompileAndPersistRunPlan(CompileAndPersistRunPlanRequest{RunID: runID, PlanID: "plan-approved-semantic", WorkflowDefinitionRef: workflowRef.Digest, ProcessDefinitionRef: processRef.Digest, PolicyContextHash: "sha256:" + strings.Repeat("5", 64), ApprovedInputSetDigest: semanticDigest}) + if err != nil { + t.Fatalf("first CompileAndPersistRunPlan returned error: %v", err) + } + identityA, cacheKeyA, err := compileIdentityFromInput(workflowRef.Digest, processRef.Digest, semanticDigest, mustCompileInputForIdentityTest(t, s, runID, "plan-approved-semantic", workflowRef.Digest, processRef.Digest, "sha256:"+strings.Repeat("5", 64))) + if err != nil { + t.Fatalf("compileIdentityFromInput returned error: %v", err) + } + if got := identityA.ApprovedInputSetDigest; got != semanticDigest { + t.Fatalf("ApprovedInputSetDigest = %q, want %q", got, semanticDigest) + } + identityB, cacheKeyB, err := compileIdentityFromInput(workflowRef.Digest, processRef.Digest, semanticDigest, mustCompileInputForIdentityTest(t, s, runID, "plan-approved-semantic", workflowRef.Digest, processRef.Digest, "sha256:"+strings.Repeat("5", 64))) + if err != nil { + t.Fatalf("compileIdentityFromInput returned error: %v", err) + } + if cacheKeyA != cacheKeyB { + t.Fatalf("semantic digest stable cache key mismatch: %q vs %q", cacheKeyA, cacheKeyB) + } + second, err := s.CompileAndPersistRunPlan(CompileAndPersistRunPlanRequest{RunID: runID, PlanID: "plan-approved-semantic", WorkflowDefinitionRef: workflowRef.Digest, ProcessDefinitionRef: processRef.Digest, PolicyContextHash: "sha256:" + strings.Repeat("5", 64), ApprovedInputSetDigest: semanticDigest}) + if err != nil { + t.Fatalf("second CompileAndPersistRunPlan returned error: %v", err) + } + if first.RunPlanDigest != second.RunPlanDigest { + t.Fatalf("semantic approved input digest should preserve cache identity: first=%+v second=%+v", first, second) + } + if identityB.ApprovedInputSetDigest == first.RunPlanDigest || identityB.ApprovedInputSetDigest == workflowRef.Digest { + t.Fatalf("compile identity conflated artifact identity with semantic digest: %+v", identityB) + } +} + +func mustCompileInputForIdentityTest(t *testing.T, s *Service, runID, planID, workflowRef, processRef, policyContextHash string) runplan.CompileInput { + t.Helper() + input, _, _, err := s.compileRunPlanInputFromArtifacts(CompileAndPersistRunPlanRequest{RunID: runID, PlanID: planID, WorkflowDefinitionRef: workflowRef, ProcessDefinitionRef: processRef, PolicyContextHash: policyContextHash}) + if err != nil { + t.Fatalf("compileRunPlanInputFromArtifacts returned error: %v", err) + } + return input +} + func TestCompileAndPersistRunPlanCoalescesInFlightIdenticalRequests(t *testing.T) { s := newTrustedRunPlanBrokerService(t) runID := "run-compile-coalesce" diff --git a/internal/brokerapi/local_api_runner_report_ops_checkpoint_test.go b/internal/brokerapi/local_api_runner_report_ops_checkpoint_test.go index 436f0f19..c4f62b35 100644 --- a/internal/brokerapi/local_api_runner_report_ops_checkpoint_test.go +++ b/internal/brokerapi/local_api_runner_report_ops_checkpoint_test.go @@ -95,6 +95,7 @@ func TestRunnerCheckpointReportRejectsUnknownCheckpointCode(t *testing.T) { func TestRunnerCheckpointReportProjectsApprovalWaitIntoSessionExecution(t *testing.T) { s := newBrokerAPIServiceForTests(t, APIConfig{}) + s.sessionExecutionRunner = launchSessionExecutionRunnerCheckpointOnlyInProcessForTests now := time.Date(2026, 4, 1, 18, 0, 0, 0, time.UTC) s.SetNowFuncForTests(func() time.Time { return now }) seedSessionRuntimeFactsForOpsTest(t, s, "run-checkpoint-session", "sess-checkpoint-session") diff --git a/internal/brokerapi/local_api_session_execution_projection_helpers.go b/internal/brokerapi/local_api_session_execution_projection_helpers.go index 149894b4..fa53bb9b 100644 --- a/internal/brokerapi/local_api_session_execution_projection_helpers.go +++ b/internal/brokerapi/local_api_session_execution_projection_helpers.go @@ -64,9 +64,6 @@ func fromDurableWorkflowRouting(in artifacts.SessionWorkflowPackRoutingDurableSt if out.WorkflowFamily == "" { out.WorkflowFamily = "runecontext" } - if out.WorkflowOperation == "" { - out.WorkflowOperation = "approved_change_implementation" - } if len(in.BoundInputArtifacts) == 0 { return out } diff --git a/internal/brokerapi/local_api_session_execution_runner_bridge.go b/internal/brokerapi/local_api_session_execution_runner_bridge.go new file mode 100644 index 00000000..8160f8a5 --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_runner_bridge.go @@ -0,0 +1,144 @@ +package brokerapi + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/launcherbackend" +) + +type sessionExecutionRunnerLaunchFunc func(context.Context, *Service, sessionExecutionRunnerLaunchSpec) error + +type sessionExecutionRunnerLaunchSpec struct { + requestID string + runID string + planID string + planPath string + sessionID string + runnerRoot string +} + +func (s *Service) bridgeSessionExecutionTriggerToRun(ctx context.Context, requestID string, result artifacts.SessionExecutionTriggerAppendResult, authority sessionExecutionPlanAuthority) error { + if ctx == nil { + return fmt.Errorf("session execution bridge context is required") + } + runID := strings.TrimSpace(authority.runID) + if runID == "" { + return fmt.Errorf("trusted run id missing for session execution bridge") + } + planDigest := strings.TrimSpace(authority.runPlanDigest) + if planDigest == "" { + return fmt.Errorf("trusted run plan digest missing for run %q", runID) + } + planPath, err := s.exportSessionExecutionRunPlan(planDigest, authority.planID) + if err != nil { + return err + } + defer os.Remove(planPath) + defer os.RemoveAll(filepath.Dir(planPath)) + if err := s.markSessionExecutionRunnerLaunching(runID, result.Trigger.SessionID); err != nil { + return err + } + runnerRepoRoot := strings.TrimSpace(s.projectSubstrate.RepositoryRoot) + if runnerRepoRoot == "" { + runnerRepoRoot = strings.TrimSpace(s.apiConfig.RepositoryRoot) + } + if err := s.sessionExecutionRunner(ctx, s, sessionExecutionRunnerLaunchSpec{ + requestID: requestID, + runID: runID, + planID: authority.planID, + planPath: planPath, + sessionID: strings.TrimSpace(result.Trigger.SessionID), + runnerRoot: runnerRepoRoot, + }); err != nil { + if markErr := s.markSessionExecutionRunnerLaunchFailed(runID, result.Trigger.SessionID, err); markErr != nil { + return fmt.Errorf("%v; additionally failed to persist runner launch failure: %w", err, markErr) + } + return err + } + return nil +} + +func (s *Service) exportSessionExecutionRunPlan(planDigest, planID string) (string, error) { + payload, err := s.readArtifactPayloadVerified(planDigest) + if err != nil { + return "", fmt.Errorf("read trusted run plan %q: %w", strings.TrimSpace(planDigest), err) + } + parentDir, err := os.MkdirTemp("", "runecode-plan-root-") + if err != nil { + return "", fmt.Errorf("create trusted run plan root: %w", err) + } + name := "runplan-*.json" + if trimmed := strings.TrimSpace(planID); trimmed != "" { + name = fmt.Sprintf("runplan-%s-*.json", sessionExecutionIdentifierToken(trimmed)) + } + path, err := writeSessionExecutionTemporaryFile(parentDir, name, payload) + if err != nil { + _ = os.RemoveAll(parentDir) + return "", fmt.Errorf("persist trusted run plan payload: %w", err) + } + return path, nil +} + +func writeSessionExecutionTemporaryFile(parentDir, pattern string, payload []byte) (string, error) { + file, err := os.CreateTemp(parentDir, pattern) + if err != nil { + return "", err + } + defer file.Close() + if _, err := file.Write(payload); err != nil { + _ = os.Remove(file.Name()) + return "", err + } + if err := file.Sync(); err != nil { + _ = os.Remove(file.Name()) + return "", err + } + return file.Name(), nil +} + +func (s *Service) markSessionExecutionRunnerLaunching(runID, sessionID string) error { + if err := s.SetRunStatus(runID, "starting"); err != nil { + return err + } + return s.RecordRuntimeFacts(runID, launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: launcherbackend.BackendLaunchReceipt{ + RunID: runID, + SessionID: strings.TrimSpace(sessionID), + BackendKind: launcherbackend.BackendKindContainer, + IsolationAssuranceLevel: launcherbackend.IsolationAssuranceUnknown, + Lifecycle: &launcherbackend.BackendLifecycleSnapshot{CurrentState: launcherbackend.BackendLifecycleStateLaunching}, + }}) +} + +func (s *Service) markSessionExecutionRunnerLaunchFailed(runID, sessionID string, launchErr error) error { + if err := s.SetRunStatus(runID, "failed"); err != nil { + return err + } + return s.RecordRuntimeFacts(runID, launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: launcherbackend.BackendLaunchReceipt{ + RunID: runID, + SessionID: strings.TrimSpace(sessionID), + BackendKind: launcherbackend.BackendKindContainer, + IsolationAssuranceLevel: launcherbackend.IsolationAssuranceUnknown, + LaunchFailureReasonCode: "runner_stdio_bridge_failed", + Lifecycle: &launcherbackend.BackendLifecycleSnapshot{CurrentState: launcherbackend.BackendLifecycleStateTerminated}, + }, TerminalReport: &launcherbackend.BackendTerminalReport{ + RunID: runID, + SessionID: strings.TrimSpace(sessionID), + TerminationKind: launcherbackend.BackendTerminationKindFailed, + FailureReasonCode: "runner_stdio_bridge_failed", + FailClosed: true, + FallbackPosture: launcherbackend.BackendFallbackPostureNoAutomaticFallback, + }}) +} + +func requestIDForRunnerTransport(requestID, runID, kind string, messageIndex int) string { + base := strings.TrimSpace(requestID) + if base == "" { + base = "runner-bridge" + } + return fmt.Sprintf("%s:%s:%s:%d", base, strings.TrimSpace(runID), kind, messageIndex) +} diff --git a/internal/brokerapi/local_api_session_execution_runner_bridge_context_test.go b/internal/brokerapi/local_api_session_execution_runner_bridge_context_test.go new file mode 100644 index 00000000..1a1633cf --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_runner_bridge_context_test.go @@ -0,0 +1,146 @@ +package brokerapi + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +func TestBridgeSessionExecutionTriggerToRunPreservesContextCancellation(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + runID := "run-session-bridge-cancelled" + if err := s.SetRunStatus(runID, "starting"); err != nil { + t.Fatalf("SetRunStatus returned error: %v", err) + } + result := sessionExecutionPlanAuthorityAppendResult(s, runID) + authority, err := s.ensureSessionExecutionRunPlanAuthority(result) + if err != nil { + t.Fatalf("ensureSessionExecutionRunPlanAuthority returned error: %v", err) + } + observedCancellation := false + s.sessionExecutionRunner = func(ctx context.Context, _ *Service, _ sessionExecutionRunnerLaunchSpec) error { + observedCancellation = errors.Is(ctx.Err(), context.Canceled) + return ctx.Err() + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err = s.bridgeSessionExecutionTriggerToRun(ctx, "req-session-bridge-cancelled", result, authority) + if !errors.Is(err, context.Canceled) { + t.Fatalf("bridgeSessionExecutionTriggerToRun error = %v, want context canceled", err) + } + if !observedCancellation { + t.Fatal("sessionExecutionRunner did not observe canceled context") + } + if status := s.RunStatuses()[runID]; status != "failed" { + t.Fatalf("run status = %q, want failed", status) + } + runtimeFacts := s.RuntimeFacts(runID) + if runtimeFacts.TerminalReport == nil || !runtimeFacts.TerminalReport.FailClosed { + t.Fatalf("runtime terminal report = %+v, want fail_closed true", runtimeFacts.TerminalReport) + } +} + +func TestBridgeSessionExecutionTriggerToRunPreservesContextDeadline(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + runID := "run-session-bridge-deadline" + if err := s.SetRunStatus(runID, "starting"); err != nil { + t.Fatalf("SetRunStatus returned error: %v", err) + } + result := sessionExecutionPlanAuthorityAppendResult(s, runID) + authority, err := s.ensureSessionExecutionRunPlanAuthority(result) + if err != nil { + t.Fatalf("ensureSessionExecutionRunPlanAuthority returned error: %v", err) + } + deadline := time.Now().Add(30 * time.Second).UTC().Round(0) + observedDeadline := time.Time{} + s.sessionExecutionRunner = func(ctx context.Context, _ *Service, _ sessionExecutionRunnerLaunchSpec) error { + var ok bool + observedDeadline, ok = ctx.Deadline() + if !ok { + t.Fatal("sessionExecutionRunner context missing deadline") + } + return nil + } + ctx, cancel := context.WithDeadline(context.Background(), deadline) + defer cancel() + if err := s.bridgeSessionExecutionTriggerToRun(ctx, "req-session-bridge-deadline", result, authority); err != nil { + t.Fatalf("bridgeSessionExecutionTriggerToRun returned error: %v", err) + } + if !observedDeadline.Equal(deadline) { + t.Fatalf("observed deadline = %s, want %s", observedDeadline.Format(time.RFC3339Nano), deadline.Format(time.RFC3339Nano)) + } +} + +func TestSessionExecutionRunnerSubprocessContextIgnoresCallerCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + runnerCtx, stopRunner := sessionExecutionRunnerSubprocessContext(ctx) + defer stopRunner(nil) + if err := runnerCtx.Err(); err != nil { + t.Fatalf("runnerCtx.Err() = %v, want nil", err) + } + if _, ok := runnerCtx.Deadline(); ok { + t.Fatal("runnerCtx unexpectedly preserved canceled caller deadline") + } +} + +func TestSessionExecutionRunnerSubprocessContextAllowsIntentionalShutdown(t *testing.T) { + runnerCtx, stopRunner := sessionExecutionRunnerSubprocessContext(context.Background()) + stopRunner(context.Canceled) + if err := runnerCtx.Err(); !errors.Is(err, context.Canceled) { + t.Fatalf("runnerCtx.Err() = %v, want context canceled", err) + } + if cause := context.Cause(runnerCtx); !errors.Is(cause, context.Canceled) { + t.Fatalf("context.Cause(runnerCtx) = %v, want context canceled", cause) + } +} + +func TestLaunchSessionExecutionRunnerSubprocessRejectsAlreadyCanceledRequestContext(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := launchSessionExecutionRunnerSubprocess(ctx, s, sessionExecutionRunnerLaunchSpec{}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("launchSessionExecutionRunnerSubprocess error = %v, want context canceled", err) + } +} + +func TestLaunchSessionExecutionRunnerSubprocessRejectsExpiredRequestContext(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + err := launchSessionExecutionRunnerSubprocess(ctx, s, sessionExecutionRunnerLaunchSpec{}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("launchSessionExecutionRunnerSubprocess error = %v, want deadline exceeded", err) + } +} + +func TestBridgeSessionExecutionTriggerToRunRejectsNilContext(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + runID := "run-session-bridge-nil-context" + result := sessionExecutionPlanAuthorityAppendResult(s, runID) + authority, err := s.ensureSessionExecutionRunPlanAuthority(result) + if err != nil { + t.Fatalf("ensureSessionExecutionRunPlanAuthority returned error: %v", err) + } + err = s.bridgeSessionExecutionTriggerToRun(nil, "req-session-bridge-nil-context", result, authority) + if err == nil || !strings.Contains(err.Error(), "context is required") { + t.Fatalf("bridgeSessionExecutionTriggerToRun error = %v, want context required detail", err) + } +} + +func TestBridgeSessionExecutionTriggerToRunRejectsMissingAuthorityRunID(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + result := sessionExecutionPlanAuthorityAppendResult(s, "run-session-bridge-missing-authority") + authority, err := s.ensureSessionExecutionRunPlanAuthority(result) + if err != nil { + t.Fatalf("ensureSessionExecutionRunPlanAuthority returned error: %v", err) + } + authority.runID = "" + err = s.bridgeSessionExecutionTriggerToRun(context.Background(), "req-session-bridge-missing-authority", result, authority) + if err == nil || !strings.Contains(err.Error(), "trusted run id missing") { + t.Fatalf("bridgeSessionExecutionTriggerToRun error = %v, want trusted run id missing detail", err) + } +} diff --git a/internal/brokerapi/local_api_session_execution_runner_bridge_launch.go b/internal/brokerapi/local_api_session_execution_runner_bridge_launch.go new file mode 100644 index 00000000..7fe942dc --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_runner_bridge_launch.go @@ -0,0 +1,256 @@ +package brokerapi + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const ( + sessionExecutionRunnerStderrCaptureLimit = 8 * 1024 + sessionExecutionRunnerStderrSummaryLimit = 512 + sessionExecutionRunnerStderrSummarySuffix = " [truncated]" +) + +func launchSessionExecutionRunnerSubprocess(ctx context.Context, s *Service, spec sessionExecutionRunnerLaunchSpec) error { + if ctx == nil { + return fmt.Errorf("runner subprocess context is required") + } + if err := sessionExecutionRunnerRequestLifecycleErr(ctx); err != nil { + return err + } + prepared, err := prepareSessionExecutionRunnerLaunch(s, spec) + if err != nil { + return err + } + defer os.RemoveAll(prepared.stateRoot) + runnerCtx, stopRunner := sessionExecutionRunnerSubprocessContext(ctx) + defer stopRunner(nil) + cmd := exec.CommandContext(runnerCtx, prepared.command[0], prepared.command[1:]...) + cmd.Dir = prepared.runnerRoot + cmd.Env = prepared.env + stdin, stdout, stderr, err := openSessionExecutionRunnerPipes(cmd) + if err != nil { + return err + } + if err := sessionExecutionRunnerRequestLifecycleErr(ctx); err != nil { + _ = stdin.Close() + return err + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("launch runner subprocess: %w", err) + } + stderrBytes, stderrDone := captureSessionExecutionRunnerStderr(stderr) + return waitForSessionExecutionRunner(ctx, s, spec, runnerCtx, stopRunner, cmd, stdin, stdout, stderrBytes, stderrDone) +} + +type preparedSessionExecutionRunnerLaunch struct { + runnerRoot string + stateRoot string + command []string + env []string +} + +func prepareSessionExecutionRunnerLaunch(s *Service, spec sessionExecutionRunnerLaunchSpec) (preparedSessionExecutionRunnerLaunch, error) { + runnerRoot, err := resolveRunnerLaunchRoot(s, spec) + if err != nil { + return preparedSessionExecutionRunnerLaunch{}, err + } + if _, err := os.Stat(filepath.Join(runnerRoot, "package.json")); err != nil { + return preparedSessionExecutionRunnerLaunch{}, fmt.Errorf("runner launch root missing package.json: %w", err) + } + if err := validateSessionExecutionRunnerInstall(runnerRoot); err != nil { + return preparedSessionExecutionRunnerLaunch{}, err + } + stateRoot, err := os.MkdirTemp(filepath.Dir(spec.planPath), "runecode-runner-state-") + if err != nil { + return preparedSessionExecutionRunnerLaunch{}, fmt.Errorf("create runner state root: %w", err) + } + nodePath, err := resolveRunnerNodePath(s) + if err != nil { + return preparedSessionExecutionRunnerLaunch{}, err + } + command, err := sessionExecutionRunnerCommand(nodePath, runnerRoot, spec.planPath, stateRoot) + if err != nil { + return preparedSessionExecutionRunnerLaunch{}, err + } + return preparedSessionExecutionRunnerLaunch{ + runnerRoot: runnerRoot, + stateRoot: stateRoot, + command: command, + env: sessionExecutionRunnerEnv(filepath.Join(filepath.Dir(runnerRoot), "protocol", "schemas"), stateRoot), + }, nil +} + +func openSessionExecutionRunnerPipes(cmd *exec.Cmd) (io.WriteCloser, io.Reader, io.Reader, error) { + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, nil, nil, fmt.Errorf("open runner stdin: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, nil, nil, fmt.Errorf("open runner stdout: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, nil, nil, fmt.Errorf("open runner stderr: %w", err) + } + return stdin, stdout, stderr, nil +} + +func captureSessionExecutionRunnerStderr(stderr io.Reader) (*boundedSessionExecutionRunnerStderrCapture, <-chan struct{}) { + stderrBytes := newBoundedSessionExecutionRunnerStderrCapture(sessionExecutionRunnerStderrCaptureLimit) + stderrDone := make(chan struct{}) + go func() { + _, _ = io.Copy(stderrBytes, stderr) + close(stderrDone) + }() + return stderrBytes, stderrDone +} + +type boundedSessionExecutionRunnerStderrCapture struct { + builder strings.Builder + remaining int + truncated bool +} + +func newBoundedSessionExecutionRunnerStderrCapture(limit int) *boundedSessionExecutionRunnerStderrCapture { + return &boundedSessionExecutionRunnerStderrCapture{remaining: limit} +} + +func (c *boundedSessionExecutionRunnerStderrCapture) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + if c.remaining <= 0 { + c.truncated = true + return len(p), nil + } + keep := len(p) + if keep > c.remaining { + keep = c.remaining + c.truncated = true + } + _, _ = c.builder.Write(p[:keep]) + c.remaining -= keep + if keep < len(p) { + c.truncated = true + } + return len(p), nil +} + +func (c *boundedSessionExecutionRunnerStderrCapture) String() string { + return c.builder.String() +} + +func (c *boundedSessionExecutionRunnerStderrCapture) Truncated() bool { + return c.truncated +} + +func resolveRunnerLaunchRoot(s *Service, spec sessionExecutionRunnerLaunchSpec) (string, error) { + overrideRoot := strings.TrimSpace(spec.runnerRoot) + if overrideRoot == "" && strings.TrimSpace(s.projectSubstrate.RepositoryRoot) == "" && strings.TrimSpace(s.apiConfig.RepositoryRoot) == "" { + return "", fmt.Errorf("resolve runner launch root: repository root is required") + } + if overrideRoot != "" { + if runnerRoot, ok := firstRunnerRootCandidate(overrideRoot); ok { + return runnerRoot, nil + } + if runnerRoot, ok := directRunnerRootCandidate(overrideRoot); ok { + return runnerRoot, nil + } + } + if runnerRoot, ok := firstRunnerRootCandidate(strings.TrimSpace(s.projectSubstrate.RepositoryRoot), strings.TrimSpace(s.apiConfig.RepositoryRoot)); ok { + return runnerRoot, nil + } + return "", fmt.Errorf("resolve runner launch root: repository root missing runner/package.json") +} + +func directRunnerRootCandidate(candidate string) (string, bool) { + root := strings.TrimSpace(candidate) + if root == "" { + return "", false + } + clean := filepath.Clean(root) + if _, err := os.Stat(filepath.Join(clean, "package.json")); err == nil { + return clean, true + } + return "", false +} + +func firstRunnerRootCandidate(candidates ...string) (string, bool) { + for _, candidate := range candidates { + root := strings.TrimSpace(candidate) + if root == "" { + continue + } + clean := filepath.Clean(root) + runnerRoot := filepath.Join(clean, "runner") + if _, err := os.Stat(filepath.Join(runnerRoot, "package.json")); err == nil { + return runnerRoot, true + } + } + return "", false +} + +func validateSessionExecutionRunnerInstall(runnerRoot string) error { + for _, dependency := range []string{"ajv", "ajv-formats"} { + if _, err := os.Stat(filepath.Join(runnerRoot, "node_modules", dependency, "package.json")); err != nil { + return fmt.Errorf("runner launch root missing installed runtime dependency %q; run (cd runner && npm ci): %w", dependency, err) + } + } + return nil +} + +func sessionExecutionRunnerCommand(nodePath, repoRoot, planPath, stateRoot string) ([]string, error) { + cliPath := filepath.Join(repoRoot, "src", "cli.ts") + planRoot := filepath.Dir(planPath) + return []string{nodePath, "--experimental-strip-types", cliPath, "--plan-file", planPath, "--plan-root", planRoot, "--state-root", stateRoot, "--broker-transport", "stdio"}, nil +} + +func resolveRunnerNodePath(s *Service) (string, error) { + configured := strings.TrimSpace(s.apiConfig.RunnerNodePath) + if configured != "" { + if !filepath.IsAbs(configured) { + return "", fmt.Errorf("configured runner node path must be absolute") + } + return configured, nil + } + resolved, err := exec.LookPath("node") + if err != nil { + return "", fmt.Errorf("resolve node runtime for runner launch: %w", err) + } + if !filepath.IsAbs(resolved) { + return "", fmt.Errorf("resolved node runtime must be absolute") + } + return resolved, nil +} + +func summarizeRunnerStderr(raw string, truncated bool) string { + trimmed := strings.TrimSpace(raw) + trimmed = strings.ReplaceAll(trimmed, "\n", " | ") + trimmed = strings.ReplaceAll(trimmed, "\r", "") + trimmed = strings.TrimSpace(trimmed) + if trimmed == "" { + if truncated { + return strings.TrimSpace(sessionExecutionRunnerStderrSummarySuffix) + } + return "none" + } + suffix := "" + if truncated { + suffix = sessionExecutionRunnerStderrSummarySuffix + } + limit := sessionExecutionRunnerStderrSummaryLimit - len(suffix) + if limit < 0 { + limit = 0 + } + if len(trimmed) > limit { + trimmed = trimmed[:limit] + } + return trimmed + suffix +} diff --git a/internal/brokerapi/local_api_session_execution_runner_bridge_launch_env.go b/internal/brokerapi/local_api_session_execution_runner_bridge_launch_env.go new file mode 100644 index 00000000..f3c9f157 --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_runner_bridge_launch_env.go @@ -0,0 +1,73 @@ +package brokerapi + +import ( + "os" + "runtime" + "strings" +) + +func sessionExecutionRunnerEnv(protocolSchemasRoot, stateRoot string) []string { + env := sessionExecutionRunnerBaseEnv(runtime.GOOS, stateRoot, os.LookupEnv) + env = append(env, + "LANG=C", + "LC_ALL=C", + "RUNECODE_PROTOCOL_SCHEMAS_ROOT="+protocolSchemasRoot, + ) + return env +} + +func sessionExecutionRunnerBaseEnv(goos, stateRoot string, lookupEnv func(string) (string, bool)) []string { + env := []string{"PATH=" + firstNonEmpty(lookupRunnerEnvValue(lookupEnv, "PATH", "Path"), defaultSessionExecutionRunnerPath(goos))} + for _, variable := range sessionExecutionRunnerOptionalEnvVars(goos, stateRoot) { + value := strings.TrimSpace(variable.value) + if value == "" { + value = strings.TrimSpace(lookupRunnerEnvValue(lookupEnv, variable.aliases...)) + } + if value != "" { + env = append(env, variable.key+"="+value) + } + } + return env +} + +type sessionExecutionRunnerEnvVar struct { + key string + value string + aliases []string +} + +func sessionExecutionRunnerOptionalEnvVars(goos, stateRoot string) []sessionExecutionRunnerEnvVar { + vars := []sessionExecutionRunnerEnvVar{{key: "HOME", value: stateRoot}, {key: "TMPDIR", value: stateRoot}, {key: "TEMP", value: stateRoot}, {key: "TMP", value: stateRoot}} + if goos != "windows" { + return vars + } + roamingRoot := strings.TrimRight(stateRoot, `\/`) + `\AppData\Roaming` + localRoot := strings.TrimRight(stateRoot, `\/`) + `\AppData\Local` + return append(vars, + sessionExecutionRunnerEnvVar{key: "USERPROFILE", value: stateRoot}, + sessionExecutionRunnerEnvVar{key: "APPDATA", value: roamingRoot}, + sessionExecutionRunnerEnvVar{key: "LOCALAPPDATA", value: localRoot}, + sessionExecutionRunnerEnvVar{key: "SystemRoot", aliases: []string{"SystemRoot", "SYSTEMROOT", "windir", "WINDIR"}}, + sessionExecutionRunnerEnvVar{key: "ComSpec", aliases: []string{"ComSpec", "COMSPEC"}}, + sessionExecutionRunnerEnvVar{key: "PATHEXT", aliases: []string{"PATHEXT"}}, + ) +} + +func lookupRunnerEnvValue(lookupEnv func(string) (string, bool), keys ...string) string { + for _, key := range keys { + if strings.TrimSpace(key) == "" { + continue + } + if value, ok := lookupEnv(key); ok { + return value + } + } + return "" +} + +func defaultSessionExecutionRunnerPath(goos string) string { + if goos == "windows" { + return `C:\Windows\System32;C:\Windows` + } + return "/usr/bin:/bin" +} diff --git a/internal/brokerapi/local_api_session_execution_runner_bridge_launch_lifecycle.go b/internal/brokerapi/local_api_session_execution_runner_bridge_launch_lifecycle.go new file mode 100644 index 00000000..2ec9dc95 --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_runner_bridge_launch_lifecycle.go @@ -0,0 +1,93 @@ +package brokerapi + +import ( + "context" + "errors" + "fmt" + "io" + "os/exec" +) + +func sessionExecutionRunnerSubprocessContext(ctx context.Context) (context.Context, context.CancelCauseFunc) { + return context.WithCancelCause(context.WithoutCancel(ctx)) +} + +func sessionExecutionRunnerRequestLifecycleErr(ctx context.Context) error { + if ctx == nil { + return nil + } + select { + case <-ctx.Done(): + if err := context.Cause(ctx); err != nil { + return err + } + return ctx.Err() + default: + return nil + } +} + +func waitForSessionExecutionRunner(requestCtx context.Context, s *Service, spec sessionExecutionRunnerLaunchSpec, runnerCtx context.Context, stopRunner context.CancelCauseFunc, cmd *exec.Cmd, stdin io.WriteCloser, stdout io.Reader, stderrBytes *boundedSessionExecutionRunnerStderrCapture, stderrDone <-chan struct{}) error { + transportDone := make(chan struct{}) + requestLifecycleErr, requestDone := watchSessionExecutionRunnerRequestLifecycle(requestCtx, stopRunner, transportDone) + handleErr := make(chan error, 1) + go func() { + err := s.proxyRunnerTransport(runnerCtx, spec.requestID, spec.runID, stdin, stdout) + _ = stdin.Close() + close(transportDone) + handleErr <- err + }() + transportErr := <-handleErr + waitErr := cmd.Wait() + <-stderrDone + <-requestDone + if err := terminalRequestLifecycleErr(requestLifecycleErr); err != nil { + return err + } + if transportErr != nil { + return runnerTransportFailure(transportErr, waitErr, stderrBytes) + } + if waitErr != nil { + return fmt.Errorf("runner subprocess failed: %v (stderr: %s)", waitErr, summarizeRunnerStderr(stderrBytes.String(), stderrBytes.Truncated())) + } + return nil +} + +func watchSessionExecutionRunnerRequestLifecycle(requestCtx context.Context, stopRunner context.CancelCauseFunc, transportDone <-chan struct{}) (<-chan error, <-chan struct{}) { + requestLifecycleErr := make(chan error, 1) + requestDone := make(chan struct{}) + go func() { + select { + case <-requestCtx.Done(): + err := sessionExecutionRunnerRequestLifecycleErr(requestCtx) + if err != nil { + stopRunner(err) + select { + case requestLifecycleErr <- err: + default: + } + } + case <-transportDone: + } + close(requestDone) + }() + return requestLifecycleErr, requestDone +} + +func terminalRequestLifecycleErr(requestLifecycleErr <-chan error) error { + select { + case err := <-requestLifecycleErr: + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + default: + } + return nil +} + +func runnerTransportFailure(transportErr, waitErr error, stderrBytes *boundedSessionExecutionRunnerStderrCapture) error { + if waitErr != nil { + return fmt.Errorf("runner transport failed: %v (runner exit: %v; stderr: %s)", transportErr, waitErr, summarizeRunnerStderr(stderrBytes.String(), stderrBytes.Truncated())) + } + return fmt.Errorf("runner transport failed: %v (stderr: %s)", transportErr, summarizeRunnerStderr(stderrBytes.String(), stderrBytes.Truncated())) +} diff --git a/internal/brokerapi/local_api_session_execution_runner_bridge_test.go b/internal/brokerapi/local_api_session_execution_runner_bridge_test.go new file mode 100644 index 00000000..0a5e25cf --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_runner_bridge_test.go @@ -0,0 +1,223 @@ +package brokerapi + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSessionExecutionRunnerCommandUsesAbsoluteNodePathAndPlanRoot(t *testing.T) { + command, err := sessionExecutionRunnerCommand("/usr/bin/node", "/repo/runner", "/private/runplan/root/runplan.json", "/private/runplan/root/state") + if err != nil { + t.Fatalf("sessionExecutionRunnerCommand returned error: %v", err) + } + joined := strings.Join(command, " ") + for _, want := range []string{"/usr/bin/node", "--plan-file", "/private/runplan/root/runplan.json", "--plan-root", "/private/runplan/root", "--state-root", "/private/runplan/root/state", "--broker-transport", "stdio"} { + if !strings.Contains(joined, want) { + t.Fatalf("sessionExecutionRunnerCommand = %v, want token %q", command, want) + } + } +} + +func TestResolveRunnerNodePathRejectsRelativeConfiguredPath(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{RunnerNodePath: "node"}) + _, err := resolveRunnerNodePath(s) + if err == nil { + t.Fatal("resolveRunnerNodePath expected error for relative configured path") + } + if !strings.Contains(err.Error(), "must be absolute") { + t.Fatalf("resolveRunnerNodePath error = %q, want absolute path detail", err) + } +} + +func TestResolveRunnerLaunchRootFailsClosedWithoutRepositoryRoot(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + s.projectSubstrate.RepositoryRoot = "" + s.apiConfig.RepositoryRoot = "" + _, err := resolveRunnerLaunchRoot(s, sessionExecutionRunnerLaunchSpec{}) + if err == nil { + t.Fatal("resolveRunnerLaunchRoot expected error when repository root is missing") + } + if !strings.Contains(err.Error(), "repository root is required") { + t.Fatalf("resolveRunnerLaunchRoot error = %q, want repository root required detail", err) + } +} + +func TestResolveRunnerLaunchRootAcceptsDirectRunnerRootOverride(t *testing.T) { + repoRoot := repositoryRootForProjectSubstrateTests(t) + s := newBrokerAPIServiceForTests(t, APIConfig{}) + s.projectSubstrate.RepositoryRoot = "" + s.apiConfig.RepositoryRoot = "" + runnerRoot, err := resolveRunnerLaunchRoot(s, sessionExecutionRunnerLaunchSpec{runnerRoot: filepath.Join(repoRoot, "runner")}) + if err != nil { + t.Fatalf("resolveRunnerLaunchRoot returned error: %v", err) + } + if want := filepath.Join(repoRoot, "runner"); runnerRoot != want { + t.Fatalf("runnerRoot = %q, want %q", runnerRoot, want) + } +} + +func TestResolveRunnerLaunchRootAcceptsRepositoryRootOverride(t *testing.T) { + repoRoot := repositoryRootForProjectSubstrateTests(t) + s := newBrokerAPIServiceForTests(t, APIConfig{}) + s.projectSubstrate.RepositoryRoot = "" + s.apiConfig.RepositoryRoot = "" + runnerRoot, err := resolveRunnerLaunchRoot(s, sessionExecutionRunnerLaunchSpec{runnerRoot: repoRoot}) + if err != nil { + t.Fatalf("resolveRunnerLaunchRoot returned error: %v", err) + } + if want := filepath.Join(repoRoot, "runner"); runnerRoot != want { + t.Fatalf("runnerRoot = %q, want %q", runnerRoot, want) + } +} + +func TestValidateSessionExecutionRunnerInstallRejectsMissingRuntimeDependencies(t *testing.T) { + runnerRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(runnerRoot, "package.json"), []byte("{}"), 0o644); err != nil { + t.Fatalf("WriteFile package.json returned error: %v", err) + } + err := validateSessionExecutionRunnerInstall(runnerRoot) + if err == nil { + t.Fatal("validateSessionExecutionRunnerInstall error = nil, want missing dependency failure") + } + if !strings.Contains(err.Error(), `missing installed runtime dependency "ajv"`) { + t.Fatalf("validateSessionExecutionRunnerInstall error = %q, want ajv dependency detail", err) + } + if !strings.Contains(err.Error(), "npm ci") { + t.Fatalf("validateSessionExecutionRunnerInstall error = %q, want remediation detail", err) + } +} + +func TestValidateSessionExecutionRunnerInstallAcceptsInstalledRuntimeDependencies(t *testing.T) { + runnerRoot := t.TempDir() + for _, relative := range []string{ + "package.json", + filepath.Join("node_modules", "ajv", "package.json"), + filepath.Join("node_modules", "ajv-formats", "package.json"), + } { + path := filepath.Join(runnerRoot, relative) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll %q returned error: %v", relative, err) + } + if err := os.WriteFile(path, []byte("{}"), 0o644); err != nil { + t.Fatalf("WriteFile %q returned error: %v", relative, err) + } + } + if err := validateSessionExecutionRunnerInstall(runnerRoot); err != nil { + t.Fatalf("validateSessionExecutionRunnerInstall returned error: %v", err) + } +} + +func TestWriteSessionExecutionTemporaryFileUsesPrivateParentDirectory(t *testing.T) { + parent := t.TempDir() + path, err := writeSessionExecutionTemporaryFile(parent, "runplan-*.json", []byte("{}")) + if err != nil { + t.Fatalf("writeSessionExecutionTemporaryFile returned error: %v", err) + } + if filepath.Dir(path) != parent { + t.Fatalf("temporary file dir = %q, want %q", filepath.Dir(path), parent) + } +} + +func TestSummarizeRunnerStderrTruncatesAndNormalizes(t *testing.T) { + raw := strings.Repeat("x", 600) + "\nsecond-line" + summary := summarizeRunnerStderr(raw, false) + if len(summary) > 512 { + t.Fatalf("summary len = %d, want <= 512", len(summary)) + } + if strings.Contains(summary, "\n") { + t.Fatalf("summary = %q, want newlines normalized", summary) + } +} + +func TestCaptureSessionExecutionRunnerStderrBoundsBufferedSize(t *testing.T) { + stderr := strings.NewReader(strings.Repeat("runner stderr line\n", 2000)) + capture, done := captureSessionExecutionRunnerStderr(stderr) + <-done + if got := len(capture.String()); got != sessionExecutionRunnerStderrCaptureLimit { + t.Fatalf("captured stderr len = %d, want %d", got, sessionExecutionRunnerStderrCaptureLimit) + } + if !capture.Truncated() { + t.Fatal("capture.Truncated() = false, want true") + } + if capture.String() == "" { + t.Fatal("captured stderr unexpectedly empty") + } +} + +func TestSummarizeRunnerStderrAppendsTruncationSuffixDeterministically(t *testing.T) { + raw := " first line\nsecond line\r\n" + summary := summarizeRunnerStderr(raw, true) + want := "first line | second line [truncated]" + if summary != want { + t.Fatalf("summary = %q, want %q", summary, want) + } + if len(summary) > sessionExecutionRunnerStderrSummaryLimit { + t.Fatalf("summary len = %d, want <= %d", len(summary), sessionExecutionRunnerStderrSummaryLimit) + } +} + +func TestSessionExecutionRunnerEnvSanitizesInheritedEnvironment(t *testing.T) { + t.Setenv("PATH", "/custom/bin") + t.Setenv("HOME", "/tmp/home") + t.Setenv("TMPDIR", "/tmp/runtime") + t.Setenv("AWS_SECRET_ACCESS_KEY", "secret") + env := sessionExecutionRunnerEnv("/repo/protocol/schemas", "/isolated/state-root") + joined := strings.Join(env, "\n") + for _, want := range []string{ + "PATH=/custom/bin", + "HOME=/isolated/state-root", + "TMPDIR=/isolated/state-root", + "TEMP=/isolated/state-root", + "TMP=/isolated/state-root", + "RUNECODE_PROTOCOL_SCHEMAS_ROOT=/repo/protocol/schemas", + "LANG=C", + "LC_ALL=C", + } { + if !strings.Contains(joined, want) { + t.Fatalf("sessionExecutionRunnerEnv missing %q in %q", want, joined) + } + } + if strings.Contains(joined, "AWS_SECRET_ACCESS_KEY=") { + t.Fatalf("sessionExecutionRunnerEnv unexpectedly leaked secret env: %q", joined) + } +} + +func TestSessionExecutionRunnerBaseEnvWindowsKeepsRequiredMinimalVariables(t *testing.T) { + lookup := func(key string) (string, bool) { + values := map[string]string{ + "Path": `C:\\node;C:\\Windows\\System32`, + "USERPROFILE": `C:\\Users\\runner`, + "TEMP": `C:\\Temp`, + "LOCALAPPDATA": `C:\\Users\\runner\\AppData\\Local`, + "SYSTEMROOT": `C:\\Windows`, + "COMSPEC": `C:\\Windows\\System32\\cmd.exe`, + "PATHEXT": `.COM;.EXE;.BAT;.CMD`, + "SECRET_TOKEN": "should-not-leak", + } + value, ok := values[key] + return value, ok + } + env := sessionExecutionRunnerBaseEnv("windows", `C:\isolated\runner-state`, lookup) + joined := strings.Join(env, "\n") + for _, want := range []string{ + `PATH=C:\\node;C:\\Windows\\System32`, + `HOME=C:\isolated\runner-state`, + `USERPROFILE=C:\isolated\runner-state`, + `TEMP=C:\isolated\runner-state`, + `TMP=C:\isolated\runner-state`, + `APPDATA=C:\isolated\runner-state\AppData\Roaming`, + `LOCALAPPDATA=C:\isolated\runner-state\AppData\Local`, + `SystemRoot=C:\\Windows`, + `ComSpec=C:\\Windows\\System32\\cmd.exe`, + `PATHEXT=.COM;.EXE;.BAT;.CMD`, + } { + if !strings.Contains(joined, want) { + t.Fatalf("sessionExecutionRunnerBaseEnv missing %q in %q", want, joined) + } + } + if strings.Contains(joined, "SECRET_TOKEN=") { + t.Fatalf("sessionExecutionRunnerBaseEnv unexpectedly leaked secret env: %q", joined) + } +} diff --git a/internal/brokerapi/local_api_session_execution_runner_bridge_transport.go b/internal/brokerapi/local_api_session_execution_runner_bridge_transport.go new file mode 100644 index 00000000..4f7ea8e4 --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_runner_bridge_transport.go @@ -0,0 +1,117 @@ +package brokerapi + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "strings" +) + +type stdioRunnerTransportRequest struct { + MessageType string `json:"message_type"` + Payload json.RawMessage `json:"payload"` +} + +type stdioRunnerTransportResponse struct { + MessageType string `json:"message_type"` + Payload any `json:"payload"` +} + +func (s *Service) proxyRunnerTransport(ctx context.Context, requestID, runID string, stdin io.WriteCloser, stdout io.Reader) error { + defer stdin.Close() + decoder := bufio.NewScanner(stdout) + buffer := make([]byte, 0, 64*1024) + decoder.Buffer(buffer, s.apiConfig.Limits.MaxMessageBytes) + encoder := json.NewEncoder(stdin) + messageIndex := 0 + for decoder.Scan() { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + messageIndex++ + response, err := s.handleRunnerTransportLine(ctx, requestID, runID, decoder.Bytes(), messageIndex) + if err != nil { + return err + } + if err := encoder.Encode(response); err != nil { + return fmt.Errorf("write typed broker response: %w", err) + } + } + if err := decoder.Err(); err != nil { + return fmt.Errorf("read runner transport message: %w", err) + } + return nil +} + +func (s *Service) handleRunnerTransportLine(ctx context.Context, requestID, runID string, line []byte, messageIndex int) (stdioRunnerTransportResponse, error) { + message := stdioRunnerTransportRequest{} + if err := json.Unmarshal(line, &message); err != nil { + return stdioRunnerTransportResponse{}, fmt.Errorf("parse runner stdio message %d: %w", messageIndex, err) + } + switch strings.TrimSpace(message.MessageType) { + case "dependency_cache_handoff_request": + return s.handleDependencyCacheHandoffTransport(ctx, requestID, runID, message.Payload, messageIndex) + case "runner_checkpoint_report_request": + return s.handleRunnerCheckpointTransport(ctx, requestID, runID, message.Payload, messageIndex) + case "runner_result_report_request": + return s.handleRunnerResultTransport(ctx, requestID, runID, message.Payload, messageIndex) + default: + return stdioRunnerTransportResponse{}, fmt.Errorf("unsupported runner transport message_type %q", strings.TrimSpace(message.MessageType)) + } +} + +func (s *Service) handleDependencyCacheHandoffTransport(ctx context.Context, requestID, runID string, payload json.RawMessage, messageIndex int) (stdioRunnerTransportResponse, error) { + var req DependencyCacheHandoffRequest + if err := json.Unmarshal(payload, &req); err != nil { + return stdioRunnerTransportResponse{}, fmt.Errorf("decode dependency cache handoff request: %w", err) + } + resp, errResp := s.HandleDependencyCacheHandoff(ctx, req, RequestContext{RequestID: requestIDForRunnerTransport(requestID, runID, "dependency_cache_handoff", messageIndex)}) + if errResp != nil { + return stdioRunnerTransportResponse{}, fmt.Errorf("dependency cache handoff rejected: %s", strings.TrimSpace(errResp.Error.Message)) + } + return stdioRunnerTransportResponse{MessageType: "dependency_cache_handoff_response", Payload: resp}, nil +} + +func (s *Service) handleRunnerCheckpointTransport(ctx context.Context, requestID, runID string, payload json.RawMessage, messageIndex int) (stdioRunnerTransportResponse, error) { + var req RunnerCheckpointReportRequest + if err := json.Unmarshal(payload, &req); err != nil { + return stdioRunnerTransportResponse{}, fmt.Errorf("decode runner checkpoint request: %w", err) + } + if err := validateBridgedRunnerReportRunID(req.RunID, runID); err != nil { + return stdioRunnerTransportResponse{}, fmt.Errorf("reject runner checkpoint request: %w", err) + } + resp, errResp := s.HandleRunnerCheckpointReport(ctx, req, RequestContext{RequestID: requestIDForRunnerTransport(requestID, runID, "checkpoint", messageIndex)}) + if errResp != nil { + return stdioRunnerTransportResponse{}, fmt.Errorf("runner checkpoint report rejected: %s", strings.TrimSpace(errResp.Error.Message)) + } + return stdioRunnerTransportResponse{MessageType: "runner_checkpoint_report_response", Payload: resp}, nil +} + +func (s *Service) handleRunnerResultTransport(ctx context.Context, requestID, runID string, payload json.RawMessage, messageIndex int) (stdioRunnerTransportResponse, error) { + var req RunnerResultReportRequest + if err := json.Unmarshal(payload, &req); err != nil { + return stdioRunnerTransportResponse{}, fmt.Errorf("decode runner result request: %w", err) + } + if err := validateBridgedRunnerReportRunID(req.RunID, runID); err != nil { + return stdioRunnerTransportResponse{}, fmt.Errorf("reject runner result request: %w", err) + } + resp, errResp := s.HandleRunnerResultReport(ctx, req, RequestContext{RequestID: requestIDForRunnerTransport(requestID, runID, "result", messageIndex)}) + if errResp != nil { + return stdioRunnerTransportResponse{}, fmt.Errorf("runner result report rejected: %s", strings.TrimSpace(errResp.Error.Message)) + } + return stdioRunnerTransportResponse{MessageType: "runner_result_report_response", Payload: resp}, nil +} + +func validateBridgedRunnerReportRunID(requestRunID, bridgedRunID string) error { + if strings.TrimSpace(requestRunID) == "" { + return fmt.Errorf("run_id is required") + } + if requestRunID != bridgedRunID { + return fmt.Errorf("run_id must match bridged run_id") + } + return nil +} diff --git a/internal/brokerapi/local_api_session_execution_runner_bridge_transport_test.go b/internal/brokerapi/local_api_session_execution_runner_bridge_transport_test.go new file mode 100644 index 00000000..0b66d39d --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_runner_bridge_transport_test.go @@ -0,0 +1,182 @@ +package brokerapi + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" +) + +func TestHandleRunnerTransportLineRejectsCheckpointRunIDMismatchOrEmpty(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + now := time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC) + putRunnerSeedArtifact(t, s, "run-payload") + + tests := []struct { + name string + requestRunID string + wantErr string + }{ + {name: "empty", requestRunID: "", wantErr: "run_id is required"}, + {name: "mismatch", requestRunID: "run-payload", wantErr: "run_id must match bridged run_id"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + line := mustMarshalRunnerTransportLine(t, "runner_checkpoint_report_request", RunnerCheckpointReportRequest{ + SchemaID: "runecode.protocol.v0.RunnerCheckpointReportRequest", + SchemaVersion: "0.1.0", + RequestID: "req-checkpoint-transport", + RunID: tt.requestRunID, + Report: RunnerCheckpointReport{ + SchemaID: "runecode.protocol.v0.RunnerCheckpointReport", + SchemaVersion: "0.1.0", + LifecycleState: "active", + CheckpointCode: "step_attempt_started", + OccurredAt: now.Format(time.RFC3339), + IdempotencyKey: "idem-checkpoint-transport", + }, + }) + + _, err := s.handleRunnerTransportLine(context.Background(), "bridge-request", "run-bridge", line, 1) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("handleRunnerTransportLine error = %v, want substring %q", err, tt.wantErr) + } + }) + } +} + +func TestHandleRunnerTransportLineRejectsResultRunIDMismatchOrEmpty(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + now := time.Date(2026, 4, 3, 11, 0, 0, 0, time.UTC) + if err := s.SetRunStatus("run-payload", "active"); err != nil { + t.Fatalf("SetRunStatus returned error: %v", err) + } + + tests := []struct { + name string + requestRunID string + wantErr string + }{ + {name: "empty", requestRunID: "", wantErr: "run_id is required"}, + {name: "mismatch", requestRunID: "run-payload", wantErr: "run_id must match bridged run_id"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + line := runnerResultTransportLineForTests(t, tt.requestRunID, now) + + _, err := s.handleRunnerTransportLine(context.Background(), "bridge-request", "run-bridge", line, 1) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("handleRunnerTransportLine error = %v, want substring %q", err, tt.wantErr) + } + }) + } +} + +func TestHandleRunnerTransportLineAcceptsMatchingRunID(t *testing.T) { + t.Run("checkpoint", testHandleRunnerTransportLineAcceptsMatchingCheckpointRunID) + t.Run("result", testHandleRunnerTransportLineAcceptsMatchingResultRunID) +} + +func testHandleRunnerTransportLineAcceptsMatchingCheckpointRunID(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + now := time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC) + putRunnerSeedArtifact(t, s, "run-bridge") + + line := mustMarshalRunnerTransportLine(t, "runner_checkpoint_report_request", RunnerCheckpointReportRequest{ + SchemaID: "runecode.protocol.v0.RunnerCheckpointReportRequest", + SchemaVersion: "0.1.0", + RequestID: "req-checkpoint-transport", + RunID: "run-bridge", + Report: RunnerCheckpointReport{ + SchemaID: "runecode.protocol.v0.RunnerCheckpointReport", + SchemaVersion: "0.1.0", + LifecycleState: "active", + CheckpointCode: "step_attempt_started", + OccurredAt: now.Format(time.RFC3339), + IdempotencyKey: "idem-checkpoint-transport", + }, + }) + + resp, err := s.handleRunnerTransportLine(context.Background(), "bridge-request", "run-bridge", line, 2) + if err != nil { + t.Fatalf("handleRunnerTransportLine returned error: %v", err) + } + payload, ok := resp.Payload.(RunnerCheckpointReportResponse) + if !ok { + t.Fatalf("response payload type = %T, want RunnerCheckpointReportResponse", resp.Payload) + } + if resp.MessageType != "runner_checkpoint_report_response" || payload.RequestID != "req-checkpoint-transport" || payload.RunID != "run-bridge" || !payload.Accepted { + t.Fatalf("unexpected checkpoint transport response: %+v payload=%+v", resp, payload) + } +} + +func testHandleRunnerTransportLineAcceptsMatchingResultRunID(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + now := time.Date(2026, 4, 3, 12, 30, 0, 0, time.UTC) + if err := s.SetRunStatus("run-bridge", "active"); err != nil { + t.Fatalf("SetRunStatus returned error: %v", err) + } + + line := mustMarshalRunnerTransportLine(t, "runner_result_report_request", RunnerResultReportRequest{ + SchemaID: "runecode.protocol.v0.RunnerResultReportRequest", + SchemaVersion: "0.1.0", + RequestID: "req-result-transport", + RunID: "run-bridge", + Report: RunnerResultReport{ + SchemaID: "runecode.protocol.v0.RunnerResultReport", + SchemaVersion: "0.1.0", + LifecycleState: "failed", + ResultCode: "run_failed", + OccurredAt: now.Format(time.RFC3339), + IdempotencyKey: "idem-result-transport", + FailureReasonCode: "policy_denied", + }, + }) + + resp, err := s.handleRunnerTransportLine(context.Background(), "bridge-request", "run-bridge", line, 3) + if err != nil { + t.Fatalf("handleRunnerTransportLine returned error: %v", err) + } + payload, ok := resp.Payload.(RunnerResultReportResponse) + if !ok { + t.Fatalf("response payload type = %T, want RunnerResultReportResponse", resp.Payload) + } + if resp.MessageType != "runner_result_report_response" || payload.RequestID != "req-result-transport" || payload.RunID != "run-bridge" || !payload.Accepted { + t.Fatalf("unexpected result transport response: %+v payload=%+v", resp, payload) + } +} + +func runnerResultTransportLineForTests(t *testing.T, runID string, occurredAt time.Time) []byte { + t.Helper() + return mustMarshalRunnerTransportLine(t, "runner_result_report_request", RunnerResultReportRequest{ + SchemaID: "runecode.protocol.v0.RunnerResultReportRequest", + SchemaVersion: "0.1.0", + RequestID: "req-result-transport", + RunID: runID, + Report: RunnerResultReport{ + SchemaID: "runecode.protocol.v0.RunnerResultReport", + SchemaVersion: "0.1.0", + LifecycleState: "failed", + ResultCode: "run_failed", + OccurredAt: occurredAt.Format(time.RFC3339), + IdempotencyKey: "idem-result-transport", + FailureReasonCode: "policy_denied", + }, + }) +} + +func mustMarshalRunnerTransportLine(t *testing.T, messageType string, payload any) []byte { + t.Helper() + payloadBytes, err := json.Marshal(payload) + if err != nil { + t.Fatalf("json.Marshal payload returned error: %v", err) + } + line, err := json.Marshal(stdioRunnerTransportRequest{MessageType: messageType, Payload: payloadBytes}) + if err != nil { + t.Fatalf("json.Marshal transport request returned error: %v", err) + } + return line +} diff --git a/internal/brokerapi/local_api_session_execution_runner_test_support_test.go b/internal/brokerapi/local_api_session_execution_runner_test_support_test.go new file mode 100644 index 00000000..f439c187 --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_runner_test_support_test.go @@ -0,0 +1,139 @@ +package brokerapi + +import ( + "context" + "time" + + "github.com/runecode-ai/runecode/internal/artifacts" +) + +func launchSessionExecutionRunnerInProcessForTests(ctx context.Context, s *Service, spec sessionExecutionRunnerLaunchSpec) error { + return launchSessionExecutionRunnerCheckpointOnlyInProcessForTests(ctx, s, spec) +} + +func launchSessionExecutionRunnerCheckpointOnlyInProcessForTests(ctx context.Context, s *Service, spec sessionExecutionRunnerLaunchSpec) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + entry, err := activeSessionExecutionPlanEntryForTests(s, spec.runID) + if err != nil { + return err + } + return reportSessionExecutionCheckpointForTests(ctx, s, spec, entry) +} + +func launchSessionExecutionRunnerCompleteInProcessForTests(ctx context.Context, s *Service, spec sessionExecutionRunnerLaunchSpec) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + entry, err := activeSessionExecutionPlanEntryForTests(s, spec.runID) + if err != nil { + return err + } + if err := reportSessionExecutionCheckpointForTests(ctx, s, spec, entry); err != nil { + return err + } + return reportSessionExecutionResultForTests(ctx, s, spec, entry) +} + +func activeSessionExecutionPlanEntryForTests(s *Service, runID string) (artifacts.RunPlanGateEntryRecord, error) { + authority, ok, err := s.ActiveRunPlanAuthority(runID) + if err != nil { + return artifacts.RunPlanGateEntryRecord{}, err + } + if !ok { + return artifacts.RunPlanGateEntryRecord{}, runnerBridgeError("checkpoint", "trusted run plan authority missing") + } + entry, err := selectSessionExecutionPlanEntry(authority.Entries) + if err != nil { + return artifacts.RunPlanGateEntryRecord{}, err + } + return entry, nil +} + +func reportSessionExecutionCheckpointForTests(ctx context.Context, s *Service, spec sessionExecutionRunnerLaunchSpec, entry artifacts.RunPlanGateEntryRecord) error { + now := time.Now().UTC() + checkpoint := RunnerCheckpointReportRequest{ + SchemaID: "runecode.protocol.v0.RunnerCheckpointReportRequest", + SchemaVersion: "0.1.0", + RequestID: spec.requestID + ":test-checkpoint", + RunID: spec.runID, + Report: RunnerCheckpointReport{ + SchemaID: "runecode.protocol.v0.RunnerCheckpointReport", + SchemaVersion: "0.1.0", + LifecycleState: "active", + CheckpointCode: "gate_started", + OccurredAt: now.Format(time.RFC3339), + IdempotencyKey: spec.planID + ":test-checkpoint", + PlanCheckpointCode: entry.PlanCheckpointCode, + PlanOrderIndex: entry.PlanOrderIndex, + GateID: entry.GateID, + GateKind: entry.GateKind, + GateVersion: entry.GateVersion, + GateLifecycleState: "running", + NormalizedInputDigests: append([]string{}, entry.ExpectedInputDigests...), + StageID: entry.StageID, + StepID: entry.StepID, + RoleInstanceID: entry.RoleInstanceID, + StageAttemptID: sessionExecutionDerivedAttemptID("stage_attempt", spec.planID, 1), + StepAttemptID: sessionExecutionDerivedAttemptID("step_attempt", spec.planID, 1), + GateAttemptID: sessionExecutionDerivedAttemptID("gate_attempt", spec.planID, 1), + }, + } + if _, errResp := s.HandleRunnerCheckpointReport(ctx, checkpoint, RequestContext{}); errResp != nil { + return runnerBridgeError("checkpoint", errResp.Error.Message) + } + return nil +} + +func reportSessionExecutionResultForTests(ctx context.Context, s *Service, spec sessionExecutionRunnerLaunchSpec, entry artifacts.RunPlanGateEntryRecord) error { + now := time.Now().UTC() + result := RunnerResultReportRequest{ + SchemaID: "runecode.protocol.v0.RunnerResultReportRequest", + SchemaVersion: "0.1.0", + RequestID: spec.requestID + ":test-result", + RunID: spec.runID, + Report: RunnerResultReport{ + SchemaID: "runecode.protocol.v0.RunnerResultReport", + SchemaVersion: "0.1.0", + LifecycleState: "completed", + ResultCode: "gate_passed", + OccurredAt: now.Add(time.Second).Format(time.RFC3339), + IdempotencyKey: spec.planID + ":test-result", + PlanCheckpointCode: entry.PlanCheckpointCode, + PlanOrderIndex: entry.PlanOrderIndex, + GateID: entry.GateID, + GateKind: entry.GateKind, + GateVersion: entry.GateVersion, + GateLifecycleState: "passed", + NormalizedInputDigests: append([]string{}, entry.ExpectedInputDigests...), + StageID: entry.StageID, + StepID: entry.StepID, + RoleInstanceID: entry.RoleInstanceID, + StageAttemptID: sessionExecutionDerivedAttemptID("stage_attempt", spec.planID, 1), + StepAttemptID: sessionExecutionDerivedAttemptID("step_attempt", spec.planID, 1), + GateAttemptID: sessionExecutionDerivedAttemptID("gate_attempt", spec.planID, 1), + }, + } + if _, errResp := s.HandleRunnerResultReport(ctx, result, RequestContext{}); errResp != nil { + return runnerBridgeError("result", errResp.Error.Message) + } + return nil +} + +func runnerBridgeError(kind, message string) error { + return &bridgeFailure{kind: kind, message: message} +} + +type bridgeFailure struct { + kind string + message string +} + +func (e *bridgeFailure) Error() string { + return "runner " + e.kind + " report rejected: " + e.message +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_append.go b/internal/brokerapi/local_api_session_execution_trigger_append.go index 8bf4808c..5a2f3af8 100644 --- a/internal/brokerapi/local_api_session_execution_trigger_append.go +++ b/internal/brokerapi/local_api_session_execution_trigger_append.go @@ -39,7 +39,7 @@ func (s *Service) newSessionExecutionAppendRequest(requestID string, req Session PrimaryRunID: initialSessionExecutionPrimaryRunID(session), LinkedRunIDs: links.runIDs, LinkedApprovalIDs: links.approvalIDs, - LinkedArtifactDigests: links.artifactDigests, + LinkedArtifactDigests: sessionExecutionLinkedArtifactDigests(links.artifactDigests, req.WorkflowRouting), LinkedAuditRecordDigests: links.auditRecordDigests, BoundValidatedProjectSubstrateDigest: sessionExecutionBoundDigest(project), ExecutionState: executionState, @@ -52,6 +52,16 @@ func (s *Service) newSessionExecutionAppendRequest(requestID string, req Session }, nil } +func sessionExecutionLinkedArtifactDigests(existing []string, routing *SessionWorkflowPackRouting) []string { + merged := append([]string{}, existing...) + if routing != nil { + for _, artifact := range routing.BoundInputArtifacts { + merged = append(merged, strings.TrimSpace(artifact.ArtifactDigest)) + } + } + return uniqueSortedStrings(merged) +} + func (s *Service) sessionExecutionTriggerIdempotencyHash(requestID string, req SessionExecutionTriggerRequest, controls sessionExecutionTriggerControlValues) (string, *ErrorResponse) { idempotencyHash, err := artifacts.SessionExecutionTriggerIdempotencyHash(req.SessionID, req.TriggerSource, req.RequestedOperation, controls.approvalProfile, controls.autonomyPosture, req.UserMessageContentText, toDurableWorkflowRouting(req.WorkflowRouting)) if err != nil { diff --git a/internal/brokerapi/local_api_session_execution_trigger_approved_implementation.go b/internal/brokerapi/local_api_session_execution_trigger_approved_implementation.go new file mode 100644 index 00000000..d7c16069 --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_trigger_approved_implementation.go @@ -0,0 +1,95 @@ +package brokerapi + +import ( + "fmt" + + "github.com/runecode-ai/runecode/internal/artifacts" +) + +type approvedImplementationResolvedInput struct { + inputSetArtifactDigest string + inputSetDigest string + approvedInputDigests []string + workspaceMutationDigests []string + metadataMutationDigests []string + resolvedWorkspaceWrites []approvedImplementationWorkspaceWrite + resolvedMetadataWrites []approvedImplementationWorkspaceWrite + projectDigest string + projectSnapshotDigest string + controlInputDigest string + repoIdentityDigest string + repoStateIdentityDigest string + workflowDefinitionHash string + processDefinitionHash string +} + +type approvedImplementationWorkspaceWrite struct { + sourceDigest string + targetRelativePath string + targetAbsolutePath string + writeMode string + content []byte + contentDigest string + isLifecycleMetadata bool + actionHash string + approvalID string + stepID string + policyDecisionHash string +} + +func (s *Service) applySessionExecutionApprovedImplementation(result artifacts.SessionExecutionTriggerAppendResult, authority sessionExecutionPlanAuthority) ([]string, []string, error) { + resolved, err := s.resolveApprovedImplementationInput(result, authority) + if err != nil { + return nil, nil, err + } + prepared, err := prepareApprovedImplementationResolvedWrites(resolved) + if err != nil { + return nil, nil, err + } + var approvalIDs []string + artifactDigests := approvedImplementationArtifactDigests(resolved) + if err := finalizeBrokerOwnedMutationWrites(prepared, func() error { + var finalizeErr error + approvalIDs, finalizeErr = s.recordApprovedImplementationMutationApprovals(result, authority, &resolved) + if finalizeErr != nil { + return finalizeErr + } + if err := s.appendApprovedImplementationAuditEvent(result, authority, resolved, approvalIDs, artifactDigests); err != nil { + return fmt.Errorf("append approved implementation audit event: %w", err) + } + return nil + }); err != nil { + return nil, nil, err + } + return approvalIDs, artifactDigests, nil +} + +func (s *Service) resolveApprovedImplementationInput(result artifacts.SessionExecutionTriggerAppendResult, authority sessionExecutionPlanAuthority) (approvedImplementationResolvedInput, error) { + binding, err := resolveSingleApprovedImplementationBinding(result.TurnExecution.WorkflowRouting.BoundInputArtifacts) + if err != nil { + return approvedImplementationResolvedInput{}, err + } + repoRoot, err := approvedImplementationRepositoryRoot(s) + if err != nil { + return approvedImplementationResolvedInput{}, err + } + inputSet, err := s.loadApprovedImplementationInputSet(result.Trigger.TriggerID, binding.ArtifactDigest) + if err != nil { + return approvedImplementationResolvedInput{}, err + } + digests, err := resolveApprovedImplementationDigests(inputSet.decoded) + if err != nil { + return approvedImplementationResolvedInput{}, err + } + writes, err := s.resolveApprovedImplementationWriteGroups(repoRoot, authority, digests.workspaceMutationDigests, digests.metadataMutationDigests) + if err != nil { + return approvedImplementationResolvedInput{}, err + } + if err := validateApprovedImplementationWriteAvailability(writes.workspaceWrites, writes.metadataWrites); err != nil { + return approvedImplementationResolvedInput{}, err + } + if err := validateApprovedImplementationMutationMembership(digests.approvedInputDigests, digests.workspaceMutationDigests, digests.metadataMutationDigests); err != nil { + return approvedImplementationResolvedInput{}, err + } + return buildApprovedImplementationResolvedInput(inputSet, digests, writes, inputSet.decoded), nil +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_approved_implementation_mutation_artifact.go b/internal/brokerapi/local_api_session_execution_trigger_approved_implementation_mutation_artifact.go new file mode 100644 index 00000000..08e84e63 --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_trigger_approved_implementation_mutation_artifact.go @@ -0,0 +1,157 @@ +package brokerapi + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/trustpolicy" +) + +func (s *Service) approvedImplementationWriteIntent(payload []byte) (string, string, []byte, error) { + decoded, err := decodeApprovedImplementationMutationArtifactPayload(payload) + if err != nil { + return "", "", nil, err + } + if err := validateApprovedImplementationMutationArtifactShape(decoded); err != nil { + return "", "", nil, err + } + targetPath := strings.TrimSpace(requiredStringFromMap(decoded, "target_path")) + if targetPath == "" { + return "", "", nil, fmt.Errorf("approved implementation mutation artifact missing target_path") + } + contentDigest, ok := digestIdentityFromApprovedImplementationField(decoded, "content_digest") + if !ok { + return "", "", nil, fmt.Errorf("approved implementation mutation artifact missing content_digest") + } + contentText, contentRef, err := approvedImplementationMutationContentSource(decoded) + if err != nil { + return "", "", nil, err + } + content, err := s.resolveApprovedImplementationMutationContent(targetPath, contentText, contentRef, strings.TrimSpace(contentDigest)) + if err != nil { + return "", "", nil, err + } + if artifacts.DigestBytes(content) != strings.TrimSpace(contentDigest) { + return "", "", nil, fmt.Errorf("approved implementation mutation artifact content_digest drift for %q", targetPath) + } + writeMode := strings.TrimSpace(requiredStringFromMap(decoded, "write_mode")) + if err := validateApprovedImplementationMutationWriteMode(writeMode); err != nil { + return "", "", nil, err + } + return targetPath, writeMode, content, nil +} + +func approvedImplementationMutationContentSource(decoded map[string]any) (string, string, error) { + contentRef := strings.TrimSpace(optionalStringFromMap(decoded, "content_artifact_digest")) + contentText := requiredStringFromMap(decoded, "content") + if contentText == "" && contentRef == "" { + return "", "", fmt.Errorf("approved implementation mutation artifact missing content") + } + if contentText != "" && contentRef != "" { + return "", "", fmt.Errorf("approved implementation mutation artifact must not include both content and content_artifact_digest") + } + return contentText, contentRef, nil +} + +func (s *Service) resolveApprovedImplementationMutationContent(targetPath, contentText, contentRef, contentDigest string) ([]byte, error) { + if contentRef == "" { + return []byte(contentText), nil + } + if s == nil { + return nil, fmt.Errorf("approved implementation mutation artifact content_artifact_digest requires broker service") + } + payload, err := s.readArtifactPayloadVerified(contentRef) + if err != nil { + return nil, fmt.Errorf("read approved implementation content artifact %q: %w", contentRef, err) + } + content := append([]byte(nil), payload...) + if artifacts.DigestBytes(content) != contentDigest { + return nil, fmt.Errorf("approved implementation mutation artifact content_digest drift for %q", targetPath) + } + return content, nil +} + +func validateApprovedImplementationMutationWriteMode(writeMode string) error { + if writeMode == "" { + return fmt.Errorf("approved implementation mutation artifact missing write_mode") + } + if writeMode != "update" && writeMode != "create" { + return fmt.Errorf("approved implementation mutation artifact write_mode %q is unsupported", writeMode) + } + return nil +} + +func validateApprovedImplementationMutationArtifactShape(decoded map[string]any) error { + if err := validateApprovedImplementationMutationArtifactFields(decoded); err != nil { + return err + } + if err := validateApprovedImplementationMutationArtifactRequiredStrings(decoded); err != nil { + return err + } + return validateApprovedImplementationMutationArtifactOptionalStrings(decoded) +} + +func validateApprovedImplementationMutationArtifactFields(decoded map[string]any) error { + allowed := map[string]struct{}{ + "target_path": {}, + "content": {}, + "content_digest": {}, + "content_artifact_digest": {}, + "write_mode": {}, + } + for key := range decoded { + if _, ok := allowed[key]; !ok { + return fmt.Errorf("approved implementation mutation artifact field %q is unsupported", key) + } + } + return nil +} + +func validateApprovedImplementationMutationArtifactRequiredStrings(decoded map[string]any) error { + for _, key := range []string{"target_path", "write_mode"} { + if _, ok := decoded[key].(string); !ok { + return fmt.Errorf("approved implementation mutation artifact %s must be a string", key) + } + } + return nil +} + +func validateApprovedImplementationMutationArtifactOptionalStrings(decoded map[string]any) error { + if raw, ok := decoded["content"]; ok { + if _, ok := raw.(string); !ok { + return fmt.Errorf("approved implementation mutation artifact content must be a string") + } + } + if raw, ok := decoded["content_artifact_digest"]; ok { + if _, ok := raw.(string); !ok { + return fmt.Errorf("approved implementation mutation artifact content_artifact_digest must be a string") + } + } + return nil +} + +func decodeApprovedImplementationMutationArtifactPayload(payload []byte) (map[string]any, error) { + var decoded map[string]any + if err := json.Unmarshal(payload, &decoded); err != nil { + return nil, fmt.Errorf("decode approved implementation mutation artifact: %w", err) + } + return decoded, nil +} + +func requiredStringFromMap(in map[string]any, key string) string { + value, _ := in[key].(string) + return value +} + +func optionalStringFromMap(in map[string]any, key string) string { + value, _ := in[key].(string) + return value +} + +func digestIdentityFromApprovedImplementationValue(value map[string]any) (string, error) { + hashAlg, _ := value["hash_alg"].(string) + hash, _ := value["hash"].(string) + return (trustpolicy.Digest{HashAlg: hashAlg, Hash: hash}).Identity() +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_approved_implementation_paths.go b/internal/brokerapi/local_api_session_execution_trigger_approved_implementation_paths.go new file mode 100644 index 00000000..d5701ff0 --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_trigger_approved_implementation_paths.go @@ -0,0 +1,102 @@ +package brokerapi + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/runecode-ai/runecode/internal/projectsubstrate" +) + +func validateApprovedImplementationTargetPath(authority sessionExecutionPlanAuthority, targetRelativePath string, lifecycleMetadata bool) error { + target, err := normalizeBrokerOwnedRelativeTargetPath(targetRelativePath) + if err != nil { + return err + } + if target == "" { + return fmt.Errorf("approved implementation target path is required") + } + if lifecycleMetadata { + return validateApprovedImplementationLifecycleMetadataTargetPath(target) + } + return validateApprovedImplementationWorkspaceTargetPath(authority, target) +} + +func validateApprovedImplementationLifecycleMetadataTargetPath(target string) error { + if approvedImplementationLifecycleMetadataPathAllowed(target) { + return nil + } + return fmt.Errorf("approved implementation lifecycle metadata target path %q is outside narrow allowed scope", target) +} + +func validateApprovedImplementationWorkspaceTargetPath(authority sessionExecutionPlanAuthority, target string) error { + allowed, err := approvedImplementationCatalogPathAllowed(authority, target) + if err != nil { + return err + } + if allowed || approvedImplementationWorkspacePathAllowed(target) { + return nil + } + return fmt.Errorf("approved implementation target path %q is outside narrow broker-owned workspace mutation scope", target) +} + +func approvedImplementationCatalogPathAllowed(authority sessionExecutionPlanAuthority, target string) (bool, error) { + entry, err := builtInCatalogEntryForWorkflowOperation(authority.workflowOperation) + if err != nil { + return false, err + } + for _, allowed := range entry.WritableRuneContextPath { + prefix, err := normalizeBrokerOwnedRelativeTargetPath(allowed) + if err != nil { + return false, err + } + if pathWithinAllowedPrefix(target, prefix) { + return true, nil + } + } + return false, nil +} + +func approvedImplementationWorkspacePathAllowed(target string) bool { + target = strings.TrimSpace(target) + if pathWithinAllowedPrefix(target, projectsubstrate.CanonicalChangesPath) { + name := filepath.Base(target) + return name == projectsubstrate.CanonicalChangeProposalName || name == projectsubstrate.CanonicalChangeTasksName || name == projectsubstrate.CanonicalChangeStatusName + } + if pathWithinAllowedPrefix(target, projectsubstrate.CanonicalSpecsPath) { + return strings.HasSuffix(target, ".md") + } + return false +} + +func approvedImplementationLifecycleMetadataPathAllowed(target string) bool { + target = strings.TrimSpace(target) + if target == projectsubstrate.CanonicalConfigPath { + return true + } + if target == "runecontext/project/roadmap.md" { + return true + } + if pathWithinAllowedPrefix(target, projectsubstrate.CanonicalChangesPath) { + name := filepath.Base(target) + return name == projectsubstrate.CanonicalChangeTasksName || name == projectsubstrate.CanonicalChangeStatusName || name == "verification.md" + } + return false +} + +func approvedImplementationMutationStepID(targetRelativePath string, lifecycleMetadata bool) string { + class := "workspace_mutation" + if lifecycleMetadata { + class = "lifecycle_metadata_mutation" + } + token := sessionExecutionIdentifierToken(strings.ReplaceAll(filepath.ToSlash(strings.TrimSpace(targetRelativePath)), "/", "_")) + return "session_execution/approved_implementation_" + class + "_" + token +} + +func approvedImplementationActionHash(targetRelativePath, contentDigest, sourceDigest string) string { + return shaDigestIdentity(strings.TrimSpace(targetRelativePath) + "\n" + strings.TrimSpace(contentDigest) + "\n" + strings.TrimSpace(sourceDigest)) +} + +func approvedImplementationApprovalID(targetRelativePath, sourceDigest string) string { + return shaDigestIdentity(strings.TrimSpace(targetRelativePath) + "\n" + strings.TrimSpace(sourceDigest)) +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_approved_implementation_policy.go b/internal/brokerapi/local_api_session_execution_trigger_approved_implementation_policy.go new file mode 100644 index 00000000..369df327 --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_trigger_approved_implementation_policy.go @@ -0,0 +1,190 @@ +package brokerapi + +import ( + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/policyengine" +) + +func (s *Service) appendApprovedImplementationAuditEvent(result artifacts.SessionExecutionTriggerAppendResult, authority sessionExecutionPlanAuthority, resolved approvedImplementationResolvedInput, approvalIDs, artifactDigests []string) error { + return s.AppendTrustedAuditEvent("runecontext_approved_implementation_applied", "brokerapi", map[string]any{ + "run_id": strings.TrimSpace(authority.runID), + "plan_id": strings.TrimSpace(authority.planID), + "workflow_operation": strings.TrimSpace(authority.workflowOperation), + "input_set_artifact_digest": strings.TrimSpace(resolved.inputSetArtifactDigest), + "input_set_digest": strings.TrimSpace(resolved.inputSetDigest), + "approved_input_digests": append([]string{}, resolved.approvedInputDigests...), + "workspace_mutation_digests": append([]string{}, resolved.workspaceMutationDigests...), + "metadata_mutation_digests": append([]string{}, resolved.metadataMutationDigests...), + "workspace_write_count": len(resolved.resolvedWorkspaceWrites), + "metadata_write_count": len(resolved.resolvedMetadataWrites), + "project_digest": strings.TrimSpace(resolved.projectDigest), + "project_snapshot_digest": strings.TrimSpace(resolved.projectSnapshotDigest), + "control_input_digest": strings.TrimSpace(resolved.controlInputDigest), + "repo_identity_digest": strings.TrimSpace(resolved.repoIdentityDigest), + "repo_state_identity_digest": strings.TrimSpace(resolved.repoStateIdentityDigest), + "workflow_definition_hash": strings.TrimSpace(resolved.workflowDefinitionHash), + "process_definition_hash": strings.TrimSpace(resolved.processDefinitionHash), + "approval_ids": append([]string{}, approvalIDs...), + "mutation_artifact_digests": append([]string{}, artifactDigests...), + "trigger_id": strings.TrimSpace(result.Trigger.TriggerID), + "turn_id": strings.TrimSpace(result.TurnExecution.TurnID), + }) +} + +func (s *Service) recordApprovedImplementationMutationApprovals(_ artifacts.SessionExecutionTriggerAppendResult, authority sessionExecutionPlanAuthority, resolved *approvedImplementationResolvedInput) ([]string, error) { + allWrites := approvedImplementationAllWrites(resolved) + approvalIDs := make([]string, 0, len(allWrites)) + for idx := range allWrites { + write := &allWrites[idx] + if err := s.recordAndMirrorApprovedImplementationApproval(authority, *resolved, write, resolved); err != nil { + return nil, err + } + approvalIDs = append(approvalIDs, write.approvalID) + } + return uniqueSortedStrings(approvalIDs), nil +} + +func approvedImplementationAllWrites(resolved *approvedImplementationResolvedInput) []approvedImplementationWorkspaceWrite { + allWrites := append([]approvedImplementationWorkspaceWrite{}, resolved.resolvedWorkspaceWrites...) + allWrites = append(allWrites, resolved.resolvedMetadataWrites...) + return allWrites +} + +func (s *Service) recordAndMirrorApprovedImplementationApproval(authority sessionExecutionPlanAuthority, resolved approvedImplementationResolvedInput, write *approvedImplementationWorkspaceWrite, fullResolved *approvedImplementationResolvedInput) error { + decisionHash, err := s.recordApprovedImplementationPolicyDecision(authority, resolved, *write) + if err != nil { + return err + } + write.policyDecisionHash = decisionHash + if err := s.recordApprovedImplementationApproval(authority, *write); err != nil { + return err + } + reflectApprovedImplementationDecisionHash(fullResolved, *write, decisionHash) + return nil +} + +func reflectApprovedImplementationDecisionHash(resolved *approvedImplementationResolvedInput, write approvedImplementationWorkspaceWrite, decisionHash string) { + target := &resolved.resolvedWorkspaceWrites + if write.isLifecycleMetadata { + target = &resolved.resolvedMetadataWrites + } + for i := range *target { + if (*target)[i].sourceDigest == write.sourceDigest { + (*target)[i].policyDecisionHash = decisionHash + return + } + } +} + +func (s *Service) recordApprovedImplementationPolicyDecision(authority sessionExecutionPlanAuthority, resolved approvedImplementationResolvedInput, write approvedImplementationWorkspaceWrite) (string, error) { + decision := approvedImplementationPolicyDecision(authority, resolved, write) + priorRefs := stringSetFromSlice(s.PolicyDecisionRefsForRun(strings.TrimSpace(authority.runID))) + if err := s.RecordPolicyDecision(strings.TrimSpace(authority.runID), "", decision); err != nil { + return "", fmt.Errorf("record approved implementation policy decision: %w", err) + } + decisionHash, err := recordedPolicyDecisionHashForRunAndAction(s, strings.TrimSpace(authority.runID), strings.TrimSpace(write.actionHash), priorRefs) + if err != nil { + return "", fmt.Errorf("locate approved implementation policy decision hash: %w", err) + } + return decisionHash, nil +} + +func approvedImplementationPolicyDecision(authority sessionExecutionPlanAuthority, resolved approvedImplementationResolvedInput, write approvedImplementationWorkspaceWrite) policyengine.PolicyDecision { + policyInputHashes := approvedImplementationPolicyInputHashes(resolved) + relevantArtifactHashes := uniqueSortedStrings([]string{strings.TrimSpace(write.sourceDigest)}) + return policyengine.PolicyDecision{ + SchemaID: "runecode.protocol.v0.PolicyDecision", + SchemaVersion: "0.3.0", + DecisionOutcome: policyengine.DecisionRequireHumanApproval, + PolicyReasonCode: "approval_required", + ManifestHash: strings.TrimSpace(write.sourceDigest), + ActionRequestHash: strings.TrimSpace(write.actionHash), + PolicyInputHashes: policyInputHashes, + RelevantArtifactHashes: relevantArtifactHashes, + DetailsSchemaID: "runecode.protocol.details.policy.evaluation.v0", + Details: map[string]any{ + "precedence": "approval_profile_moderate", + "checkpoint_model": "workspace_write", + "workflow_operation": strings.TrimSpace(authority.workflowOperation), + }, + RequiredApprovalSchemaID: "runecode.protocol.details.policy.required_approval.out_of_workspace_write.v0", + RequiredApproval: map[string]any{ + "approval_trigger_code": "out_of_workspace_write", + "approval_assurance_level": approvalDefaultAssuranceLevel, + "presence_mode": approvalDefaultPresenceMode, + "changes_if_approved": approvedImplementationChangesIfApproved(write), + "approval_ttl_seconds": 1800, + "scope": approvedImplementationApprovalScope(authority, write), + "related_hashes": map[string]any{ + "manifest_hash": strings.TrimSpace(write.sourceDigest), + "action_request_hash": strings.TrimSpace(write.actionHash), + "policy_input_hashes": policyInputHashes, + "relevant_artifact_hashes": relevantArtifactHashes, + }, + }, + } +} + +func approvedImplementationPolicyInputHashes(resolved approvedImplementationResolvedInput) []string { + return uniqueSortedStrings([]string{ + strings.TrimSpace(resolved.projectDigest), + strings.TrimSpace(resolved.repoStateIdentityDigest), + strings.TrimSpace(resolved.controlInputDigest), + }) +} + +func approvedImplementationApprovalScope(authority sessionExecutionPlanAuthority, write approvedImplementationWorkspaceWrite) map[string]any { + return map[string]any{ + "schema_id": "runecode.protocol.v0.ApprovalBoundScope", + "schema_version": "0.1.0", + "workspace_id": workspaceIDForRun(authority.runID), + "run_id": strings.TrimSpace(authority.runID), + "stage_id": strings.TrimSpace(authority.stageID), + "step_id": strings.TrimSpace(write.stepID), + "role_instance_id": strings.TrimSpace(authority.roleInstanceID), + "action_kind": policyengine.ActionKindWorkspaceWrite, + } +} + +func (s *Service) recordApprovedImplementationApproval(authority sessionExecutionPlanAuthority, write approvedImplementationWorkspaceWrite) error { + now := s.currentTimestamp() + record := artifacts.ApprovalRecord{ + ApprovalID: strings.TrimSpace(write.approvalID), + Status: "consumed", + WorkspaceID: workspaceIDForRun(authority.runID), + RunID: strings.TrimSpace(authority.runID), + StageID: strings.TrimSpace(authority.stageID), + StepID: strings.TrimSpace(write.stepID), + RoleInstanceID: strings.TrimSpace(authority.roleInstanceID), + ActionKind: policyengine.ActionKindWorkspaceWrite, + RequestedAt: now, + DecidedAt: &now, + ConsumedAt: &now, + ApprovalTriggerCode: "out_of_workspace_write", + ChangesIfApproved: approvedImplementationChangesIfApproved(write), + ApprovalAssuranceLevel: approvalDefaultAssuranceLevel, + PresenceMode: approvalDefaultPresenceMode, + PolicyDecisionHash: strings.TrimSpace(write.policyDecisionHash), + ManifestHash: strings.TrimSpace(write.sourceDigest), + ActionRequestHash: strings.TrimSpace(write.actionHash), + RelevantArtifactHashes: []string{strings.TrimSpace(write.sourceDigest)}, + RequestDigest: strings.TrimSpace(write.approvalID), + DecisionDigest: strings.TrimSpace(write.policyDecisionHash), + SourceDigest: strings.TrimSpace(write.sourceDigest), + } + if err := s.RecordApproval(record); err != nil { + return fmt.Errorf("record approved implementation approval: %w", err) + } + return nil +} + +func approvedImplementationChangesIfApproved(write approvedImplementationWorkspaceWrite) string { + kind := "approved implementation file" + if write.isLifecycleMetadata { + kind = "approved RuneContext lifecycle metadata" + } + return fmt.Sprintf("Apply %s to %s.", kind, strings.TrimSpace(write.targetRelativePath)) +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_approved_implementation_resolve.go b/internal/brokerapi/local_api_session_execution_trigger_approved_implementation_resolve.go new file mode 100644 index 00000000..4b9baa6e --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_trigger_approved_implementation_resolve.go @@ -0,0 +1,231 @@ +package brokerapi + +import ( + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/artifacts" +) + +type approvedImplementationDigestSet struct { + approvedInputDigests []string + workspaceMutationDigests []string + metadataMutationDigests []string +} + +type approvedImplementationWriteSet struct { + workspaceWrites []approvedImplementationWorkspaceWrite + metadataWrites []approvedImplementationWorkspaceWrite +} + +func approvedImplementationRepositoryRoot(s *Service) (string, error) { + repoRoot := strings.TrimSpace(s.projectSubstrate.RepositoryRoot) + if repoRoot == "" { + repoRoot = strings.TrimSpace(s.apiConfig.RepositoryRoot) + } + if repoRoot == "" { + return "", fmt.Errorf("repository root is required for approved implementation") + } + return repoRoot, nil +} + +func (s *Service) loadApprovedImplementationInputSet(triggerID, inputSetArtifactDigest string) (approvedImplementationInputSetState, error) { + inputSet, errResp := s.decodeApprovedImplementationInputSet(triggerID, inputSetArtifactDigest) + if errResp != nil { + return approvedImplementationInputSetState{}, fmt.Errorf("%s", strings.TrimSpace(errResp.Error.Message)) + } + return inputSet, nil +} + +func resolveApprovedImplementationDigests(decoded map[string]any) (approvedImplementationDigestSet, error) { + approvedDigests, err := approvedImplementationDigestList(decoded, "approved_input_digests") + if err != nil { + return approvedImplementationDigestSet{}, err + } + workspaceDigests, err := approvedImplementationDigestList(decoded, "workspace_mutation_digests") + if err != nil { + return approvedImplementationDigestSet{}, err + } + metadataDigests, err := approvedImplementationDigestList(decoded, "lifecycle_metadata_mutation_digests") + if err != nil { + return approvedImplementationDigestSet{}, err + } + return approvedImplementationDigestSet{ + approvedInputDigests: approvedDigests, + workspaceMutationDigests: workspaceDigests, + metadataMutationDigests: metadataDigests, + }, nil +} + +func (s *Service) resolveApprovedImplementationWriteGroups(repoRoot string, authority sessionExecutionPlanAuthority, workspaceDigests, metadataDigests []string) (approvedImplementationWriteSet, error) { + workspaceWrites, err := s.resolveApprovedImplementationWrites(repoRoot, authority, workspaceDigests, false) + if err != nil { + return approvedImplementationWriteSet{}, err + } + metadataWrites, err := s.resolveApprovedImplementationWrites(repoRoot, authority, metadataDigests, true) + if err != nil { + return approvedImplementationWriteSet{}, err + } + return approvedImplementationWriteSet{workspaceWrites: workspaceWrites, metadataWrites: metadataWrites}, nil +} + +func validateApprovedImplementationWriteAvailability(workspaceWrites, metadataWrites []approvedImplementationWorkspaceWrite) error { + if len(workspaceWrites) == 0 && len(metadataWrites) == 0 { + return fmt.Errorf("implementation_input_set must bind at least one approved workspace or lifecycle metadata mutation") + } + return nil +} + +func validateApprovedImplementationMutationMembership(approvedDigests, workspaceDigests, metadataDigests []string) error { + allowed := map[string]struct{}{} + for _, digest := range approvedDigests { + allowed[strings.TrimSpace(digest)] = struct{}{} + } + for _, digest := range append(append([]string{}, workspaceDigests...), metadataDigests...) { + trimmed := strings.TrimSpace(digest) + if trimmed == "" { + continue + } + if _, ok := allowed[trimmed]; !ok { + return fmt.Errorf("implementation_input_set mutation digest %q is not included in approved_input_digests", trimmed) + } + } + return nil +} + +func buildApprovedImplementationResolvedInput(inputSet approvedImplementationInputSetState, digests approvedImplementationDigestSet, writes approvedImplementationWriteSet, decoded map[string]any) approvedImplementationResolvedInput { + projectDigest, projectSnapshotDigest, controlInputDigest, repoIdentityDigest, repoStateDigest, workflowHash, processHash := approvedImplementationContextDigests(decoded) + return approvedImplementationResolvedInput{ + inputSetArtifactDigest: strings.TrimSpace(inputSet.inputSetArtifactDigest), + inputSetDigest: strings.TrimSpace(inputSet.inputSetDigest), + approvedInputDigests: digests.approvedInputDigests, + workspaceMutationDigests: digests.workspaceMutationDigests, + metadataMutationDigests: digests.metadataMutationDigests, + resolvedWorkspaceWrites: writes.workspaceWrites, + resolvedMetadataWrites: writes.metadataWrites, + projectDigest: projectDigest, + projectSnapshotDigest: projectSnapshotDigest, + controlInputDigest: controlInputDigest, + repoIdentityDigest: repoIdentityDigest, + repoStateIdentityDigest: repoStateDigest, + workflowDefinitionHash: workflowHash, + processDefinitionHash: processHash, + } +} + +func approvedImplementationContextDigests(decoded map[string]any) (string, string, string, string, string, string, string) { + projectDigest, _ := digestIdentityFromApprovedImplementationField(decoded, "validated_project_substrate_digest") + projectSnapshotDigest, _ := digestIdentityFromApprovedImplementationField(decoded, "project_substrate_snapshot_digest") + controlInputDigest, _ := digestIdentityFromApprovedImplementationField(decoded, "control_input_digest") + repoIdentityDigest, _ := digestIdentityFromApprovedImplementationField(decoded, "repo_identity_digest") + repoStateDigest, _ := digestIdentityFromApprovedImplementationField(decoded, "repo_state_identity_digest") + workflowHash, _ := digestIdentityFromApprovedImplementationField(decoded, "workflow_definition_hash") + processHash, _ := digestIdentityFromApprovedImplementationField(decoded, "process_definition_hash") + return strings.TrimSpace(projectDigest), strings.TrimSpace(projectSnapshotDigest), strings.TrimSpace(controlInputDigest), strings.TrimSpace(repoIdentityDigest), strings.TrimSpace(repoStateDigest), strings.TrimSpace(workflowHash), strings.TrimSpace(processHash) +} + +func resolveSingleApprovedImplementationBinding(bindings []artifacts.SessionWorkflowPackBoundInputArtifactDurableState) (artifacts.SessionWorkflowPackBoundInputArtifactDurableState, error) { + if len(bindings) != 1 { + return artifacts.SessionWorkflowPackBoundInputArtifactDurableState{}, fmt.Errorf("approved implementation requires exactly one bound implementation input set") + } + binding := bindings[0] + if strings.TrimSpace(binding.ArtifactRef) != "implementation_input_set" { + return artifacts.SessionWorkflowPackBoundInputArtifactDurableState{}, fmt.Errorf("approved implementation bound artifact ref %q is unsupported", strings.TrimSpace(binding.ArtifactRef)) + } + if strings.TrimSpace(binding.ArtifactDigest) == "" { + return artifacts.SessionWorkflowPackBoundInputArtifactDurableState{}, fmt.Errorf("approved implementation bound implementation input set digest is required") + } + return binding, nil +} + +func approvedImplementationDigestList(decoded map[string]any, field string) ([]string, error) { + raw, ok := decoded[field] + if !ok { + return nil, nil + } + items, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("implementation_input_set %s must be an array", field) + } + out := make([]string, 0, len(items)) + for _, item := range items { + typed, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("implementation_input_set %s contains malformed digest object", field) + } + identity, err := digestIdentityFromApprovedImplementationValue(typed) + if err != nil { + return nil, fmt.Errorf("implementation_input_set %s contains invalid digest: %w", field, err) + } + out = append(out, identity) + } + return uniqueSortedStrings(out), nil +} + +func (s *Service) resolveApprovedImplementationWrites(repoRoot string, authority sessionExecutionPlanAuthority, digests []string, lifecycleMetadata bool) ([]approvedImplementationWorkspaceWrite, error) { + if len(digests) == 0 { + return nil, nil + } + out := make([]approvedImplementationWorkspaceWrite, 0, len(digests)) + for _, digest := range digests { + write, err := s.resolveApprovedImplementationWrite(repoRoot, authority, digest, lifecycleMetadata) + if err != nil { + return nil, err + } + out = append(out, write) + } + return out, nil +} + +func (s *Service) resolveApprovedImplementationWrite(repoRoot string, authority sessionExecutionPlanAuthority, digest string, lifecycleMetadata bool) (approvedImplementationWorkspaceWrite, error) { + payload, err := s.readArtifactPayloadVerified(digest) + if err != nil { + return approvedImplementationWorkspaceWrite{}, fmt.Errorf("read approved implementation artifact %q: %w", digest, err) + } + targetRelativePath, writeMode, content, err := s.approvedImplementationWriteIntent(payload) + if err != nil { + return approvedImplementationWorkspaceWrite{}, err + } + if err := validateApprovedImplementationTargetPath(authority, targetRelativePath, lifecycleMetadata); err != nil { + return approvedImplementationWorkspaceWrite{}, err + } + targetAbsolutePath, err := brokerOwnedDraftPromoteTargetPath(repoRoot, targetRelativePath) + if err != nil { + return approvedImplementationWorkspaceWrite{}, err + } + contentDigest := artifacts.DigestBytes(content) + stepID := approvedImplementationMutationStepID(targetRelativePath, lifecycleMetadata) + return approvedImplementationWorkspaceWrite{ + sourceDigest: strings.TrimSpace(digest), + targetRelativePath: strings.TrimSpace(targetRelativePath), + targetAbsolutePath: targetAbsolutePath, + writeMode: writeMode, + content: append([]byte(nil), content...), + contentDigest: contentDigest, + isLifecycleMetadata: lifecycleMetadata, + actionHash: approvedImplementationActionHash(targetRelativePath, contentDigest, digest), + approvalID: approvedImplementationApprovalID(targetRelativePath, digest), + stepID: stepID, + }, nil +} + +func prepareApprovedImplementationResolvedWrites(resolved approvedImplementationResolvedInput) ([]brokerOwnedPreparedMutationWrite, error) { + writes := append([]approvedImplementationWorkspaceWrite{}, resolved.resolvedWorkspaceWrites...) + writes = append(writes, resolved.resolvedMetadataWrites...) + intents := make([]brokerOwnedMutationWriteIntent, 0, len(writes)) + for _, write := range writes { + intents = append(intents, brokerOwnedMutationWriteIntent{ + targetAbsolutePath: write.targetAbsolutePath, + targetRelativePath: write.targetRelativePath, + writeMode: write.writeMode, + contents: write.content, + expectedDigest: write.contentDigest, + mode: 0o644, + }) + } + return prepareBrokerOwnedMutationWrites(intents) +} + +func approvedImplementationArtifactDigests(resolved approvedImplementationResolvedInput) []string { + return uniqueSortedStrings(append([]string{strings.TrimSpace(resolved.inputSetArtifactDigest)}, append(append([]string{}, resolved.workspaceMutationDigests...), resolved.metadataMutationDigests...)...)) +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_approved_implementation_test.go b/internal/brokerapi/local_api_session_execution_trigger_approved_implementation_test.go new file mode 100644 index 00000000..23b5754f --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_trigger_approved_implementation_test.go @@ -0,0 +1,147 @@ +package brokerapi + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/runecode-ai/runecode/internal/artifacts" +) + +func TestApprovedImplementationInputSetFixtureValidatesAgainstSchema(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + mutationDigest := artifacts.DigestBytes([]byte("approved-mutation")) + payload := approvedImplementationInputSetFixture(t, s, []string{mutationDigest}, []string{mutationDigest}, nil) + raw, err := artifacts.CanonicalizeJSONBytes(mustJSONMarshalForApprovedImplementationTest(t, payload)) + if err != nil { + t.Fatalf("CanonicalizeJSONBytes returned error: %v", err) + } + if err := artifacts.ValidateObjectPayloadAgainstSchema(raw, "objects/RuneContextApprovedImplementationInputSet.schema.json"); err != nil { + t.Fatalf("ValidateObjectPayloadAgainstSchema returned error: %v", err) + } +} + +func TestReadArtifactPayloadVerifiedRejectsBlobDigestDrift(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + ref, err := s.Put(artifacts.PutRequest{Payload: []byte("approved"), ContentType: "text/plain", DataClass: artifacts.DataClassSpecText, ProvenanceReceiptHash: artifacts.DigestBytes([]byte("approved")), CreatedByRole: "test", TrustedSource: true}) + if err != nil { + t.Fatalf("Put returned error: %v", err) + } + record, err := s.store.Head(ref.Digest) + if err != nil { + t.Fatalf("Head returned error: %v", err) + } + if err := os.WriteFile(record.BlobPath, []byte("tampered"), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + _, err = s.readArtifactPayloadVerified(ref.Digest) + if err == nil || !strings.Contains(err.Error(), "artifact payload digest drift") { + t.Fatalf("readArtifactPayloadVerified error = %v, want digest drift", err) + } +} + +func TestValidateApprovedImplementationWriteModeEnforcesCreateAndUpdate(t *testing.T) { + root := t.TempDir() + existing := filepath.Join(root, "existing.md") + missing := filepath.Join(root, "missing.md") + if err := os.WriteFile(existing, []byte("old"), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + if err := validateApprovedImplementationWriteMode(missing, "create"); err != nil { + t.Fatalf("create missing target returned error: %v", err) + } + if err := validateApprovedImplementationWriteMode(existing, "update"); err != nil { + t.Fatalf("update existing target returned error: %v", err) + } + if err := validateApprovedImplementationWriteMode(existing, "create"); err == nil { + t.Fatal("create existing target expected error") + } + if err := validateApprovedImplementationWriteMode(missing, "update"); err == nil { + t.Fatal("update missing target expected error") + } +} + +func TestApprovedImplementationWriteIntentRejectsUnexpectedFields(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + payload := map[string]any{ + "target_path": "runecontext/changes/CHG-test/proposal.md", + "content": "body", + "content_digest": digestObject(artifacts.DigestBytes([]byte("body"))), + "write_mode": "create", + "unexpected": "value", + } + canonical, err := artifacts.CanonicalizeJSONBytes(mustJSONMarshalForApprovedImplementationTest(t, payload)) + if err != nil { + t.Fatalf("CanonicalizeJSONBytes returned error: %v", err) + } + _, _, _, err = s.approvedImplementationWriteIntent(canonical) + if err == nil || !strings.Contains(err.Error(), "unsupported") { + t.Fatalf("approvedImplementationWriteIntent error = %v, want unsupported field", err) + } +} + +func TestApprovedImplementationWriteIntentLoadsContentArtifactDigest(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + content := []byte("artifact-backed body") + contentRef, err := s.Put(artifacts.PutRequest{Payload: content, ContentType: "text/plain", DataClass: artifacts.DataClassSpecText, ProvenanceReceiptHash: artifacts.DigestBytes(content), CreatedByRole: "test", TrustedSource: true}) + if err != nil { + t.Fatalf("Put returned error: %v", err) + } + payload := map[string]any{ + "target_path": "runecontext/changes/CHG-artifact-backed/proposal.md", + "content_artifact_digest": contentRef.Digest, + "content_digest": digestObject(artifacts.DigestBytes(content)), + "write_mode": "create", + } + canonical, err := artifacts.CanonicalizeJSONBytes(mustJSONMarshalForApprovedImplementationTest(t, payload)) + if err != nil { + t.Fatalf("CanonicalizeJSONBytes returned error: %v", err) + } + targetPath, writeMode, resolvedContent, err := s.approvedImplementationWriteIntent(canonical) + if err != nil { + t.Fatalf("approvedImplementationWriteIntent returned error: %v", err) + } + if targetPath != "runecontext/changes/CHG-artifact-backed/proposal.md" { + t.Fatalf("targetPath = %q, want artifact-backed path", targetPath) + } + if writeMode != "create" { + t.Fatalf("writeMode = %q, want create", writeMode) + } + if string(resolvedContent) != string(content) { + t.Fatalf("resolved content = %q, want %q", string(resolvedContent), string(content)) + } +} + +func TestApprovedImplementationWriteIntentRejectsContentArtifactDigestDrift(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + content := []byte("artifact-backed drift") + contentRef, err := s.Put(artifacts.PutRequest{Payload: content, ContentType: "text/plain", DataClass: artifacts.DataClassSpecText, ProvenanceReceiptHash: artifacts.DigestBytes(content), CreatedByRole: "test", TrustedSource: true}) + if err != nil { + t.Fatalf("Put returned error: %v", err) + } + payload := map[string]any{ + "target_path": "runecontext/changes/CHG-artifact-backed/proposal.md", + "content_artifact_digest": contentRef.Digest, + "content_digest": digestObject(artifacts.DigestBytes([]byte("other"))), + "write_mode": "create", + } + canonical, err := artifacts.CanonicalizeJSONBytes(mustJSONMarshalForApprovedImplementationTest(t, payload)) + if err != nil { + t.Fatalf("CanonicalizeJSONBytes returned error: %v", err) + } + _, _, _, err = s.approvedImplementationWriteIntent(canonical) + if err == nil || !strings.Contains(err.Error(), "content_digest drift") { + t.Fatalf("approvedImplementationWriteIntent error = %v, want content_digest drift", err) + } +} + +func mustJSONMarshalForApprovedImplementationTest(t *testing.T, value any) []byte { + t.Helper() + b, err := json.Marshal(value) + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + return b +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_binding.go b/internal/brokerapi/local_api_session_execution_trigger_binding.go index c95b7602..9f453d8b 100644 --- a/internal/brokerapi/local_api_session_execution_trigger_binding.go +++ b/internal/brokerapi/local_api_session_execution_trigger_binding.go @@ -1,7 +1,6 @@ package brokerapi import ( - "fmt" "strings" "github.com/runecode-ai/runecode/internal/artifacts" @@ -9,10 +8,16 @@ import ( ) func (s *Service) ensureSessionExecutionPrimaryRunBinding(requestID, sessionID string, execution artifacts.SessionTurnExecutionDurableState) (artifacts.SessionTurnExecutionDurableState, *ErrorResponse) { - if strings.TrimSpace(execution.PrimaryRunID) != "" { + if runID := strings.TrimSpace(execution.PrimaryRunID); runID != "" { + if errResp := s.ensureSessionExecutionRunBindingInitialized(requestID, sessionID, runID); errResp != nil { + return artifacts.SessionTurnExecutionDurableState{}, errResp + } return execution, nil } runID := sessionExecutionRunID(sessionID, execution.ExecutionIndex) + if errResp := s.ensureSessionExecutionRunBindingInitialized(requestID, sessionID, runID); errResp != nil { + return artifacts.SessionTurnExecutionDurableState{}, errResp + } updated, errResp := s.updateSessionExecutionRunBinding(requestID, sessionID, execution, runID) if errResp != nil { return artifacts.SessionTurnExecutionDurableState{}, errResp @@ -20,12 +25,18 @@ func (s *Service) ensureSessionExecutionPrimaryRunBinding(requestID, sessionID s if errResp := s.updateSessionRunBindingState(requestID, sessionID, runID); errResp != nil { return artifacts.SessionTurnExecutionDurableState{}, errResp } - if errResp := s.initializeSessionExecutionRunBinding(requestID, sessionID, runID); errResp != nil { - return artifacts.SessionTurnExecutionDurableState{}, errResp - } return updated, nil } +func (s *Service) ensureSessionExecutionRunBindingInitialized(requestID, sessionID, runID string) *ErrorResponse { + if _, ok := s.RunStatuses()[runID]; ok { + if facts := s.RuntimeFacts(runID); strings.TrimSpace(facts.LaunchReceipt.RunID) == runID { + return nil + } + } + return s.initializeSessionExecutionRunBinding(requestID, sessionID, runID) +} + func (s *Service) updateSessionExecutionRunBinding(requestID, sessionID string, execution artifacts.SessionTurnExecutionDurableState, runID string) (artifacts.SessionTurnExecutionDurableState, *ErrorResponse) { updated, err := s.UpdateSessionTurnExecution(artifacts.SessionTurnExecutionUpdateRequest{ SessionID: sessionID, @@ -67,7 +78,7 @@ func (s *Service) updateSessionRunBindingState(requestID, sessionID, runID strin } func (s *Service) initializeSessionExecutionRunBinding(requestID, sessionID, runID string) *ErrorResponse { - if err := s.SetRunStatus(runID, "active"); err != nil { + if err := s.SetRunStatus(runID, "starting"); err != nil { errOut := s.errorFromStore(requestID, err) return &errOut } @@ -77,10 +88,3 @@ func (s *Service) initializeSessionExecutionRunBinding(requestID, sessionID, run } return nil } - -func sessionExecutionRunID(sessionID string, executionIndex int) string { - if executionIndex < 1 { - executionIndex = 1 - } - return fmt.Sprintf("%s.run.%06d", strings.TrimSpace(sessionID), executionIndex) -} diff --git a/internal/brokerapi/local_api_session_execution_trigger_binding_test.go b/internal/brokerapi/local_api_session_execution_trigger_binding_test.go new file mode 100644 index 00000000..a3b80241 --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_trigger_binding_test.go @@ -0,0 +1,125 @@ +package brokerapi + +import ( + "regexp" + "strings" + "testing" + "time" + + "github.com/runecode-ai/runecode/internal/artifacts" +) + +func TestSessionExecutionRunIDAvoidsNormalizedSessionTokenCollisions(t *testing.T) { + first := sessionExecutionRunID("A.B", 1) + second := sessionExecutionRunID("a_b", 1) + + if first == second { + t.Fatalf("sessionExecutionRunID(A.B, 1) = %q, sessionExecutionRunID(a_b, 1) = %q; want distinct values", first, second) + } +} + +func TestSessionExecutionRunIDIsDeterministicPortableAndIndexed(t *testing.T) { + const sessionID = "sess-trigger-create" + got := sessionExecutionRunID(sessionID, 3) + wantPattern := regexp.MustCompile(`^run_sess-trigger-create_[0-9a-f]{64}_3$`) + if !wantPattern.MatchString(got) { + t.Fatalf("sessionExecutionRunID(%q, 3) = %q, want %s", sessionID, got, wantPattern.String()) + } + + if again := sessionExecutionRunID(sessionID, 3); again != got { + t.Fatalf("sessionExecutionRunID(%q, 3) = %q on repeat, want %q", sessionID, again, got) + } + + if firstIndex := sessionExecutionRunID(sessionID, 0); !regexp.MustCompile(`_1$`).MatchString(firstIndex) { + t.Fatalf("sessionExecutionRunID(%q, 0) = %q, want suffix _1", sessionID, firstIndex) + } + if len(got) > 128 { + t.Fatalf("sessionExecutionRunID(%q, 3) length = %d, want <= 128", sessionID, len(got)) + } + + longSessionID := strings.Repeat("A", 128) + if got := sessionExecutionRunID(longSessionID, 1); len(got) > 128 { + t.Fatalf("sessionExecutionRunID(longSessionID, 1) length = %d, want <= 128", len(got)) + } + if got := sessionExecutionRunID(sessionID, 1234567890); len(got) > 128 { + t.Fatalf("sessionExecutionRunID(%q, 1234567890) length = %d, want <= 128", sessionID, len(got)) + } + if !regexp.MustCompile(`_1234567890$`).MatchString(sessionExecutionRunID(sessionID, 1234567890)) { + t.Fatalf("sessionExecutionRunID(%q, 1234567890) missing full index suffix", sessionID) + } +} + +func TestSessionExecutionDerivedPlanIDIsDeterministicAndBounded(t *testing.T) { + sourceID := sessionExecutionRunID(strings.Repeat("A", 128), 1234567890) + got := sessionExecutionDerivedPlanID(sourceID, 1234567890) + if len(got) > 128 { + t.Fatalf("sessionExecutionDerivedPlanID length = %d, want <= 128", len(got)) + } + if !regexp.MustCompile(`^plan_[a-z][a-z0-9_-]*_[0-9a-f]{64}_1234567890$`).MatchString(got) { + t.Fatalf("sessionExecutionDerivedPlanID = %q, want bounded deterministic plan id", got) + } +} + +func TestSessionExecutionDerivedAttemptIDIsDeterministicAndBounded(t *testing.T) { + sourceID := sessionExecutionDerivedPlanID(sessionExecutionRunID(strings.Repeat("A", 128), 1234567890), 1234567890) + got := sessionExecutionDerivedAttemptID("stage_attempt", sourceID, 1234567890) + if len(got) > 128 { + t.Fatalf("sessionExecutionDerivedAttemptID length = %d, want <= 128", len(got)) + } + if !regexp.MustCompile(`^stage_attempt_[a-z][a-z0-9_-]*_[0-9a-f]{64}_1234567890$`).MatchString(got) { + t.Fatalf("sessionExecutionDerivedAttemptID = %q, want bounded deterministic attempt id", got) + } +} + +func TestEnsureSessionExecutionPrimaryRunBindingRepairsPreviouslyUninitializedRun(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + appendResult, runID := seedUninitializedSessionExecutionRunBinding(t, s) + if runID == "" { + t.Fatal("primary_run_id is empty") + } + updated, errResp := s.ensureSessionExecutionPrimaryRunBinding("req-session-trigger-repair-ensure", "sess-trigger-repair", appendResult.TurnExecution) + if errResp != nil { + t.Fatalf("ensureSessionExecutionPrimaryRunBinding returned error: %+v", errResp) + } + assertRecoveredSessionExecutionRunBinding(t, s, updated, runID) +} + +func seedUninitializedSessionExecutionRunBinding(t *testing.T, s *Service) (artifacts.SessionExecutionTriggerAppendResult, string) { + t.Helper() + appendResult, err := s.AppendSessionExecutionTrigger(artifacts.SessionExecutionTriggerAppendRequest{SessionID: "sess-trigger-repair", WorkspaceID: "workspace-local", AuthoritativeRepositoryRoot: "/repo/root", TriggerSource: "interactive_user", RequestedOperation: "start", ExecutionState: "running", WorkflowRouting: artifacts.SessionWorkflowPackRoutingDurableState{WorkflowFamily: "runecontext", WorkflowOperation: "change_draft"}, OccurredAt: time.Now().UTC()}) + if err != nil { + t.Fatalf("AppendSessionExecutionTrigger returned error: %v", err) + } + runID := sessionExecutionRunID("sess-trigger-repair", appendResult.TurnExecution.ExecutionIndex) + if _, err := s.UpdateSessionTurnExecution(artifacts.SessionTurnExecutionUpdateRequest{SessionID: "sess-trigger-repair", TurnID: appendResult.TurnExecution.TurnID, ExecutionState: appendResult.TurnExecution.ExecutionState, WaitKind: appendResult.TurnExecution.WaitKind, WaitState: appendResult.TurnExecution.WaitState, OrchestrationScopeID: appendResult.TurnExecution.OrchestrationScopeID, DependsOnScopeIDs: append([]string{}, appendResult.TurnExecution.DependsOnScopeIDs...), PrimaryRunID: runID, PendingApprovalID: appendResult.TurnExecution.PendingApprovalID, LinkedRunIDs: uniqueSortedStrings(append(append([]string{}, appendResult.TurnExecution.LinkedRunIDs...), runID)), LinkedApprovalIDs: append([]string{}, appendResult.TurnExecution.LinkedApprovalIDs...), LinkedArtifactDigests: append([]string{}, appendResult.TurnExecution.LinkedArtifactDigests...), LinkedAuditRecordDigests: append([]string{}, appendResult.TurnExecution.LinkedAuditRecordDigests...), BlockedReasonCode: appendResult.TurnExecution.BlockedReasonCode, TerminalOutcome: appendResult.TurnExecution.TerminalOutcome, BoundValidatedProjectSubstrateDigest: appendResult.TurnExecution.BoundValidatedProjectSubstrateDigest, OccurredAt: time.Now().UTC()}); err != nil { + t.Fatalf("UpdateSessionTurnExecution returned error: %v", err) + } + if _, err := s.UpdateSessionState("sess-trigger-repair", func(state artifacts.SessionDurableState) artifacts.SessionDurableState { + state.LinkedRunIDs = uniqueSortedStrings(append(state.LinkedRunIDs, runID)) + state.CreatedByRunID = runID + return state + }); err != nil { + t.Fatalf("UpdateSessionState returned error: %v", err) + } + return appendResult, runID +} + +func assertRecoveredSessionExecutionRunBinding(t *testing.T, s *Service, updated artifacts.SessionTurnExecutionDurableState, runID string) { + t.Helper() + if updated.PrimaryRunID != runID { + t.Fatalf("updated primary_run_id = %q, want %q", updated.PrimaryRunID, runID) + } + if status := s.RunStatuses()[runID]; status != "starting" { + t.Fatalf("run status = %q, want starting", status) + } + facts := s.RuntimeFacts(runID) + if facts.LaunchReceipt.RunID != runID { + t.Fatalf("runtime facts run_id = %q, want %q", facts.LaunchReceipt.RunID, runID) + } + if facts.LaunchReceipt.SessionID != "sess-trigger-repair" { + t.Fatalf("runtime facts session_id = %q, want sess-trigger-repair", facts.LaunchReceipt.SessionID) + } + if facts.LaunchReceipt.Lifecycle != nil && facts.LaunchReceipt.Lifecycle.CurrentState == "" { + t.Fatalf("runtime lifecycle = %+v, want empty or valid lifecycle state", facts.LaunchReceipt.Lifecycle) + } +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_bridge.go b/internal/brokerapi/local_api_session_execution_trigger_bridge.go deleted file mode 100644 index ebb106b0..00000000 --- a/internal/brokerapi/local_api_session_execution_trigger_bridge.go +++ /dev/null @@ -1,36 +0,0 @@ -package brokerapi - -import ( - "strings" - - "github.com/runecode-ai/runecode/internal/artifacts" -) - -func (s *Service) bridgeSessionExecutionTriggerToRun(runID string, result artifacts.SessionExecutionTriggerAppendResult) error { - trimmedRunID := strings.TrimSpace(runID) - if trimmedRunID == "" { - return nil - } - if _, err := s.RecordRunnerCheckpoint(trimmedRunID, sessionExecutionBridgeCheckpoint(result)); err != nil { - return err - } - return s.SetRunStatus(trimmedRunID, "active") -} - -func sessionExecutionBridgeCheckpoint(result artifacts.SessionExecutionTriggerAppendResult) artifacts.RunnerCheckpointAdvisory { - return artifacts.RunnerCheckpointAdvisory{ - LifecycleState: "active", - CheckpointCode: "run_started", - OccurredAt: result.Trigger.CreatedAt.UTC(), - IdempotencyKey: "session-trigger-" + result.Trigger.TriggerID, - Details: map[string]any{ - "session_id": result.Trigger.SessionID, - "trigger_id": result.Trigger.TriggerID, - "turn_id": result.TurnExecution.TurnID, - "trigger_source": result.Trigger.TriggerSource, - "requested_operation": result.Trigger.RequestedOperation, - "approval_profile": result.TurnExecution.ApprovalProfile, - "autonomy_posture": result.TurnExecution.AutonomyPosture, - }, - } -} diff --git a/internal/brokerapi/local_api_session_execution_trigger_broker_owned_paths.go b/internal/brokerapi/local_api_session_execution_trigger_broker_owned_paths.go new file mode 100644 index 00000000..7b4e6f04 --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_trigger_broker_owned_paths.go @@ -0,0 +1,82 @@ +package brokerapi + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func normalizeBrokerOwnedRelativeTargetPath(targetRelativePath string) (string, error) { + trimmed := strings.TrimSpace(targetRelativePath) + if trimmed == "" { + return "", nil + } + normalized := filepath.ToSlash(filepath.Clean(filepath.FromSlash(trimmed))) + if normalized == "." { + return "", nil + } + if normalized == ".." || strings.HasPrefix(normalized, "../") { + return "", fmt.Errorf("target path escapes repository root") + } + return normalized, nil +} + +func pathWithinAllowedPrefix(target, allowed string) bool { + target = strings.TrimSpace(target) + allowed = strings.TrimSpace(allowed) + if target == "" || allowed == "" { + return false + } + if target == allowed { + return true + } + return strings.HasPrefix(target, allowed+"/") +} + +func brokerOwnedDraftPromoteTargetPath(repoRoot, targetRelativePath string) (string, error) { + repoRoot = filepath.Clean(strings.TrimSpace(repoRoot)) + if repoRoot == "" { + return "", fmt.Errorf("repository root is required") + } + targetPath := filepath.Clean(filepath.Join(repoRoot, filepath.FromSlash(strings.TrimSpace(targetRelativePath)))) + rel, err := filepath.Rel(repoRoot, targetPath) + if err != nil { + return "", fmt.Errorf("resolve draft promote/apply target path: %w", err) + } + rel = filepath.ToSlash(rel) + if rel == ".." || strings.HasPrefix(rel, "../") { + return "", fmt.Errorf("draft promote/apply target path escapes repository root") + } + return targetPath, nil +} + +func writeBrokerOwnedDraftPromoteFile(path string, contents []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create draft promote/apply target parent: %w", err) + } + tmpFile, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp") + if err != nil { + return fmt.Errorf("open draft promote/apply temp file: %w", err) + } + tmpPath := tmpFile.Name() + if err := tmpFile.Chmod(mode); err != nil { + _ = tmpFile.Close() + _ = os.Remove(tmpPath) + return fmt.Errorf("chmod draft promote/apply temp file: %w", err) + } + if _, err := tmpFile.Write(contents); err != nil { + _ = tmpFile.Close() + _ = os.Remove(tmpPath) + return fmt.Errorf("write draft promote/apply temp file: %w", err) + } + if err := tmpFile.Close(); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("close draft promote/apply temp file: %w", err) + } + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("rename draft promote/apply temp file: %w", err) + } + return nil +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_continue_test.go b/internal/brokerapi/local_api_session_execution_trigger_continue_test.go new file mode 100644 index 00000000..3b8fd307 --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_trigger_continue_test.go @@ -0,0 +1,292 @@ +package brokerapi + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/launcherbackend" + "github.com/runecode-ai/runecode/internal/policyengine" + "github.com/runecode-ai/runecode/internal/projectsubstrate" + "github.com/runecode-ai/runecode/internal/trustpolicy" +) + +func TestSessionExecutionTriggerIdempotencyIncludesWorkflowRoutingIdentity(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + seedSessionRuntimeFactsForOpsTest(t, s, "run-session-trigger-routing-idem", "sess-trigger-routing-idem") + base := SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-routing-idem-1", SessionID: "sess-trigger-routing-idem", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: "draft_promote_apply"}, UserMessageContentText: "hello", IdempotencyKey: "idem-routing"} + _ = mustSessionExecutionTrigger(t, s, base) + base.RequestID = "req-session-trigger-routing-idem-2" + base.WorkflowRouting = &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: "change_draft"} + _, errResp := s.HandleSessionExecutionTrigger(context.Background(), base, RequestContext{}) + assertSessionExecutionContinueBlocked(t, errResp, "broker_idempotency_key_payload_mismatch") +} + +func TestSessionExecutionTriggerProjectsSessionRunAndSnapshotBindings(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + seedSessionRuntimeFactsForOpsTest(t, s, "run-session-trigger-links", "sess-trigger-links") + seedSessionExecutionTriggerProjectionLinks(t, s) + ack := mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-links-trigger", SessionID: "sess-trigger-links", TriggerSource: "interactive_user", RequestedOperation: "start", UserMessageContentText: "go"}) + if ack.TurnID == "" { + t.Fatal("turn_id is empty") + } + getResp := mustSessionGet(t, s, "req-session-trigger-links-get", "sess-trigger-links") + exec := requireCurrentSessionExecution(t, getResp.Session) + assertSessionExecutionBindings(t, exec) +} + +func TestSessionExecutionTriggerContinueFailsClosedOnDigestDriftAndProjectsBlockedTurn(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + seedSessionRuntimeFactsForOpsTest(t, s, "run-session-trigger-drift", "sess-trigger-drift") + _ = mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-drift-start", SessionID: "sess-trigger-drift", TriggerSource: "interactive_user", RequestedOperation: "start", UserMessageContentText: "start"}) + bound := requireBoundExecutionDigest(t, mustSessionGet(t, s, "req-session-trigger-drift-get-start", "sess-trigger-drift").Session) + driftDigest := digestForBrokerTest("session-trigger-drift") + if driftDigest == bound { + t.Fatal("test setup expected drift digest to differ from bound digest") + } + s.discoverProjectSubstrateFn = func() (projectsubstrate.DiscoveryResult, error) { + return projectsubstrate.DiscoveryResult{Snapshot: projectsubstrate.ValidationSnapshot{ValidatedSnapshotDigest: driftDigest, ProjectContextIdentityDigest: driftDigest}, Compatibility: projectsubstrate.CompatibilityAssessment{Posture: projectsubstrate.CompatibilityPostureSupportedCurrent, NormalOperationAllowed: true}}, nil + } + _, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-drift-continue", SessionID: "sess-trigger-drift", TriggerSource: "resume_follow_up", RequestedOperation: "continue", UserMessageContentText: "continue"}, RequestContext{}) + if errResp == nil { + t.Fatal("HandleSessionExecutionTrigger expected drift blocked error") + } + if errResp.Error.Code != "broker_session_execution_project_context_drift" { + t.Fatalf("error code = %q, want broker_session_execution_project_context_drift", errResp.Error.Code) + } + assertSessionExecutionBlockedProjection(t, mustSessionGet(t, s, "req-session-trigger-drift-get-blocked", "sess-trigger-drift").Session, "project_substrate_digest_drift") +} + +func TestSessionRuntimeFactsDoNotOverwriteBlockedSessionPosture(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + seedSessionRuntimeFactsForOpsTest(t, s, "run-session-blocked-preserve", "sess-blocked-preserve") + _ = mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-blocked-preserve-start", SessionID: "sess-blocked-preserve", TriggerSource: "interactive_user", RequestedOperation: "start", UserMessageContentText: "start"}) + bound := requireBoundExecutionDigest(t, mustSessionGet(t, s, "req-session-blocked-preserve-get-start", "sess-blocked-preserve").Session) + driftDigest := digestForBrokerTest("session-blocked-preserve-drift") + if driftDigest == bound { + t.Fatal("test setup expected drift digest to differ from bound digest") + } + s.discoverProjectSubstrateFn = func() (projectsubstrate.DiscoveryResult, error) { + return projectsubstrate.DiscoveryResult{Snapshot: projectsubstrate.ValidationSnapshot{ValidatedSnapshotDigest: driftDigest, ProjectContextIdentityDigest: driftDigest}, Compatibility: projectsubstrate.CompatibilityAssessment{Posture: projectsubstrate.CompatibilityPostureSupportedCurrent, NormalOperationAllowed: true}}, nil + } + _, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-blocked-preserve-continue", SessionID: "sess-blocked-preserve", TriggerSource: "resume_follow_up", RequestedOperation: "continue", UserMessageContentText: "continue"}, RequestContext{}) + if errResp == nil { + t.Fatal("HandleSessionExecutionTrigger expected drift blocked error") + } + if err := s.RecordRuntimeFacts("run-session-blocked-preserve", launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: launcherbackend.BackendLaunchReceipt{RunID: "run-session-blocked-preserve", SessionID: "sess-blocked-preserve"}}); err != nil { + t.Fatalf("RecordRuntimeFacts returned error: %v", err) + } + blockSessionPosturePreserved(t, mustSessionGet(t, s, "req-session-blocked-preserve-get-blocked", "sess-blocked-preserve").Session) +} + +func TestSessionExecutionTriggerContinueRequiresValidatedSnapshotDigest(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + seedSessionRuntimeFactsForOpsTest(t, s, "run-session-continue-digest", "sess-continue-digest") + start := mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-continue-digest-start", SessionID: "sess-continue-digest", TriggerSource: "interactive_user", RequestedOperation: "start", UserMessageContentText: "start"}) + markSessionExecutionWaiting(t, s, start.TurnID, "sess-continue-digest") + s.discoverProjectSubstrateFn = func() (projectsubstrate.DiscoveryResult, error) { + return projectsubstrate.DiscoveryResult{Snapshot: projectsubstrate.ValidationSnapshot{}, Compatibility: projectsubstrate.CompatibilityAssessment{Posture: projectsubstrate.CompatibilityPostureSupportedCurrent, NormalOperationAllowed: true}}, nil + } + _, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-continue-digest-continue", SessionID: "sess-continue-digest", TriggerSource: "resume_follow_up", RequestedOperation: "continue", UserMessageContentText: "continue"}, RequestContext{}) + assertSessionExecutionContinueBlocked(t, errResp, "project_substrate_operation_blocked") +} + +func TestSessionExecutionTriggerContinueRejectsBlockedTurnResume(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + seedSessionRuntimeFactsForOpsTest(t, s, "run-session-blocked-resume", "sess-blocked-resume") + start := mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-blocked-resume-start", SessionID: "sess-blocked-resume", TriggerSource: "interactive_user", RequestedOperation: "start", UserMessageContentText: "start"}) + markSessionExecutionBlocked(t, s, start.TurnID, "sess-blocked-resume") + resp, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-blocked-resume-continue", SessionID: "sess-blocked-resume", TriggerSource: "resume_follow_up", RequestedOperation: "continue", UserMessageContentText: "continue"}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleSessionExecutionTrigger returned error: %+v", errResp) + } + if resp.ExecutionState != "running" { + t.Fatalf("execution_state = %q, want running", resp.ExecutionState) + } +} + +func TestSessionExecutionTriggerAutonomousOperatorGuidedStartsWaitingForOperatorInput(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + seedSessionRuntimeFactsForOpsTest(t, s, "run-session-trigger-autonomous", "sess-trigger-autonomous") + ack := mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-autonomous", SessionID: "sess-trigger-autonomous", TriggerSource: "autonomous_background", RequestedOperation: "start", AutonomyPosture: "operator_guided", UserMessageContentText: "background step"}) + if ack.ExecutionState != "waiting" { + t.Fatalf("execution_state = %q, want waiting", ack.ExecutionState) + } + getResp := mustSessionGet(t, s, "req-session-trigger-autonomous-get", "sess-trigger-autonomous") + if getResp.Session.CurrentTurnExecution == nil { + t.Fatal("current_turn_execution missing") + } + if getResp.Session.CurrentTurnExecution.WaitKind != "operator_input" { + t.Fatalf("wait_kind = %q, want operator_input", getResp.Session.CurrentTurnExecution.WaitKind) + } + if getResp.Session.CurrentTurnExecution.WaitState != "waiting_operator_input" { + t.Fatalf("wait_state = %q, want waiting_operator_input", getResp.Session.CurrentTurnExecution.WaitState) + } +} + +func TestSessionExecutionTriggerContinueRejectsWaitingApprovalUntilApprovalResolves(t *testing.T) { + s, unapproved, requestEnv, decisionEnv := setupServiceWithApprovalFixture(t) + approvalID, policyDecisionHash, storedApproval := prepareSessionExecutionApprovalFixture(t, s, requestEnv) + seedSessionRuntimeFactsForOpsTest(t, s, "run-approval", "sess-trigger-waiting-approval") + ack := mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-waiting-approval-start", SessionID: "sess-trigger-waiting-approval", TriggerSource: "autonomous_background", RequestedOperation: "start", AutonomyPosture: "balanced", UserMessageContentText: "background step"}) + if ack.ExecutionState != "running" { + t.Fatalf("execution_state = %q, want running", ack.ExecutionState) + } + recordAndAssertApprovalWait(t, s, approvalID, storedApproval.ActionRequestHash) + _, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-waiting-approval-continue-blocked", SessionID: "sess-trigger-waiting-approval", TriggerSource: "resume_follow_up", RequestedOperation: "continue", UserMessageContentText: "continue"}, RequestContext{}) + assertSessionExecutionContinueBlocked(t, errResp, "broker_session_execution_continue_waiting_approval") + resolveSessionExecutionApprovalWait(t, s, approvalID, policyDecisionHash, unapproved.Digest, requestEnv, decisionEnv) + resp, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-waiting-approval-continue-resolved", SessionID: "sess-trigger-waiting-approval", TriggerSource: "resume_follow_up", RequestedOperation: "continue", UserMessageContentText: "continue"}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleSessionExecutionTrigger returned error: %+v", errResp) + } + if resp.ExecutionState != "running" { + t.Fatalf("execution_state = %q, want running", resp.ExecutionState) + } +} + +func TestSessionExecutionTriggerContinueTargetsExplicitTurn(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + seedSessionRuntimeFactsForOpsTest(t, s, "run-session-trigger-targeted", "sess-trigger-targeted") + first := mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-targeted-1", SessionID: "sess-trigger-targeted", TriggerSource: "autonomous_background", RequestedOperation: "start", AutonomyPosture: "operator_guided", UserMessageContentText: "first"}) + if _, err := s.UpdateSessionTurnExecution(artifacts.SessionTurnExecutionUpdateRequest{SessionID: "sess-trigger-targeted", TurnID: first.TurnID, ExecutionState: "waiting", WaitKind: "external_dependency", WaitState: "waiting_external_dependency", OccurredAt: s.currentTimestamp()}); err != nil { + t.Fatalf("UpdateSessionTurnExecution returned error: %v", err) + } + resp, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-targeted-continue", SessionID: "sess-trigger-targeted", TurnID: first.TurnID, TriggerSource: "resume_follow_up", RequestedOperation: "continue", UserMessageContentText: "continue first"}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleSessionExecutionTrigger returned error: %+v", errResp) + } + if resp.TurnID != first.TurnID { + t.Fatalf("continued turn_id = %q, want %q", resp.TurnID, first.TurnID) + } + getResp := mustSessionGet(t, s, "req-session-trigger-targeted-get", "sess-trigger-targeted") + if len(getResp.Session.PendingTurnExecutions) != 1 { + t.Fatalf("pending_turn_executions len = %d, want 1", len(getResp.Session.PendingTurnExecutions)) + } + if state := getResp.Session.PendingTurnExecutions[0].ExecutionState; state != "running" { + t.Fatalf("execution_state = %q, want running", state) + } +} + +func TestSessionExecutionTriggerContinueSupportsIdempotentRetry(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + seedSessionRuntimeFactsForOpsTest(t, s, "run-session-trigger-continue-idem", "sess-trigger-continue-idem") + start := mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-continue-idem-start", SessionID: "sess-trigger-continue-idem", TriggerSource: "autonomous_background", RequestedOperation: "start", AutonomyPosture: "operator_guided", UserMessageContentText: "wait first"}) + firstResp := mustSessionExecutionContinue(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-continue-idem-1", SessionID: "sess-trigger-continue-idem", TurnID: start.TurnID, TriggerSource: "resume_follow_up", RequestedOperation: "continue", UserMessageContentText: "continue", IdempotencyKey: "idem-continue-1"}) + assertStoredSessionExecutionTriggerIdempotencyRecord(t, s, "sess-trigger-continue-idem", "idem-continue-1", firstResp) + secondResp := mustSessionExecutionContinue(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-continue-idem-2", SessionID: "sess-trigger-continue-idem", TriggerSource: "resume_follow_up", RequestedOperation: "continue", UserMessageContentText: "continue", IdempotencyKey: "idem-continue-1"}) + assertSessionExecutionTriggerReplayResponse(t, secondResp, firstResp) +} + +func mustSessionExecutionContinue(t *testing.T, s *Service, req SessionExecutionTriggerRequest) SessionExecutionTriggerResponse { + t.Helper() + resp, errResp := s.HandleSessionExecutionTrigger(context.Background(), req, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleSessionExecutionTrigger returned error: %+v", errResp) + } + return resp +} + +func prepareSessionExecutionApprovalFixture(t *testing.T, s *Service, requestEnv *trustpolicy.SignedObjectEnvelope) (string, string, artifacts.ApprovalRecord) { + t.Helper() + approvalID := approvalIDForBrokerTest(t, requestEnv) + return approvalID, policyDecisionHashForStoredApproval(t, s, approvalID), mustApprovalGet(t, s, approvalID) +} + +func recordAndAssertApprovalWait(t *testing.T, s *Service, approvalID, actionHash string) { + t.Helper() + if err := s.RecordRunnerApprovalWait(artifacts.RunnerApproval{ApprovalID: approvalID, RunID: "run-approval", StageID: "artifact_flow", StepID: "step-1", RoleInstanceID: "role-1", Status: "pending", ApprovalType: "exact_action", BoundActionHash: actionHash, OccurredAt: s.currentTimestamp()}); err != nil { + t.Fatalf("RecordRunnerApprovalWait returned error: %v", err) + } + if err := s.syncSessionExecutionForRun("run-approval", s.currentTimestamp()); err != nil { + t.Fatalf("syncSessionExecutionForRun returned error: %v", err) + } + getResp := mustSessionGet(t, s, "req-session-trigger-waiting-approval-get", "sess-trigger-waiting-approval") + exec := requireCurrentSessionExecution(t, getResp.Session) + if exec.WaitKind != "approval" { + t.Fatalf("wait_kind = %q, want approval", exec.WaitKind) + } + if exec.WaitState != "waiting_approval" { + t.Fatalf("wait_state = %q, want waiting_approval", exec.WaitState) + } + if exec.PendingApprovalID != approvalID { + t.Fatalf("pending_approval_id = %q, want %q", exec.PendingApprovalID, approvalID) + } +} + +func resolveSessionExecutionApprovalWait(t *testing.T, s *Service, approvalID, policyDecisionHash, unapprovedDigest string, requestEnv, decisionEnv *trustpolicy.SignedObjectEnvelope) { + t.Helper() + resolveReq := ApprovalResolveRequest{SchemaID: "runecode.protocol.v0.ApprovalResolveRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-waiting-approval-resolve", ApprovalID: approvalID, BoundScope: ApprovalBoundScope{SchemaID: "runecode.protocol.v0.ApprovalBoundScope", SchemaVersion: "0.1.0", WorkspaceID: workspaceIDForRun("run-approval"), RunID: "run-approval", StageID: "artifact_flow", StepID: "step-1", ActionKind: policyengine.ActionKindPromotion, PolicyDecisionHash: policyDecisionHash}, UnapprovedDigest: unapprovedDigest, Approver: "human", RepoPath: "repo/file.txt", Commit: "abc123", ExtractorToolVersion: "tool-v1", FullContentVisible: true, ExplicitViewFull: false, BulkRequest: false, BulkApprovalConfirmed: false, SignedApprovalRequest: *requestEnv, SignedApprovalDecision: *decisionEnv} + if _, errResp := s.HandleApprovalResolve(context.Background(), resolveReq, RequestContext{}); errResp != nil { + t.Fatalf("HandleApprovalResolve error response: %+v", errResp) + } + resolved := mustSessionGet(t, s, "req-session-trigger-waiting-approval-get-resolved", "sess-trigger-waiting-approval") + resolvedExec := requireCurrentSessionExecution(t, resolved.Session) + if resolvedExec.WaitKind != "" { + t.Fatalf("wait_kind after resolve = %q, want empty", resolvedExec.WaitKind) + } +} + +func digestForRunStep(t *testing.T, s *Service, runID, stepID string) string { + t.Helper() + for _, record := range s.List() { + if strings.TrimSpace(record.RunID) == strings.TrimSpace(runID) && strings.TrimSpace(record.StepID) == strings.TrimSpace(stepID) { + return strings.TrimSpace(record.Reference.Digest) + } + } + t.Fatalf("artifact digest for run=%s step=%s not found", runID, stepID) + return "" +} + +func mustArtifactText(t *testing.T, s *Service, digest string) string { + t.Helper() + reader, err := s.Get(digest) + if err != nil { + t.Fatalf("Get(%q) returned error: %v", digest, err) + } + defer reader.Close() + payload, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("ReadAll(%q) returned error: %v", digest, err) + } + return string(payload) +} + +func assertStoredSessionExecutionTriggerIdempotencyRecord(t *testing.T, s *Service, sessionID, key string, resp SessionExecutionTriggerResponse) { + t.Helper() + if resp.TriggerID == "" { + t.Fatal("trigger_id is empty") + } + state, ok := s.SessionState(sessionID) + if !ok { + t.Fatal("SessionState missing") + } + record, ok := state.ExecutionTriggerIdempotencyByKey[key] + if !ok { + t.Fatal("continue idempotency record missing") + } + if record.TriggerID != resp.TriggerID { + t.Fatalf("stored trigger_id = %q, want %q", record.TriggerID, resp.TriggerID) + } + if record.TurnID != resp.TurnID { + t.Fatalf("stored turn_id = %q, want %q", record.TurnID, resp.TurnID) + } + if record.Seq != resp.Seq { + t.Fatalf("stored seq = %d, want %d", record.Seq, resp.Seq) + } +} + +func assertSessionExecutionTriggerReplayResponse(t *testing.T, got, want SessionExecutionTriggerResponse) { + t.Helper() + if got.Seq != want.Seq { + t.Fatalf("replay seq = %d, want %d", got.Seq, want.Seq) + } + if got.TurnID != want.TurnID { + t.Fatalf("replay turn_id = %q, want %q", got.TurnID, want.TurnID) + } + if got.TriggerID != want.TriggerID { + t.Fatalf("replay trigger_id = %q, want %q", got.TriggerID, want.TriggerID) + } +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_draft_artifacts.go b/internal/brokerapi/local_api_session_execution_trigger_draft_artifacts.go new file mode 100644 index 00000000..60ca337a --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_trigger_draft_artifacts.go @@ -0,0 +1,228 @@ +package brokerapi + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/runecode-ai/runecode/internal/artifacts" +) + +var nonDraftIdentityRunePattern = regexp.MustCompile(`[^a-z0-9._-]+`) + +func (s *Service) materializeSessionExecutionDraftArtifacts(result artifacts.SessionExecutionTriggerAppendResult, authority sessionExecutionPlanAuthority) ([]string, error) { + schemaID := strings.TrimSpace(authority.draftArtifactSchemaID) + if schemaID == "" { + return nil, nil + } + runID := strings.TrimSpace(authority.runID) + if runID == "" { + return nil, fmt.Errorf("draft artifact materialization requires run id") + } + promptPayload := buildSessionExecutionPromptArtifact(result, authority) + promptRef, err := s.persistSessionExecutionPromptArtifact(runID, authority, promptPayload) + if err != nil { + return nil, err + } + draftTextPayload := buildSessionExecutionDraftTextArtifact(result, authority) + draftTextRef, err := s.persistSessionExecutionDraftTextArtifact(runID, authority, draftTextPayload) + if err != nil { + return nil, err + } + draftPayload, draftRef, err := s.persistSessionExecutionTypedDraftArtifact(runID, result, authority, promptRef.Digest, draftTextRef.Digest) + if err != nil { + return nil, err + } + if err := s.validateSessionExecutionTypedDraftArtifactPayload(schemaID, draftPayload); err != nil { + return nil, err + } + return uniqueSortedStrings([]string{promptRef.Digest, draftTextRef.Digest, draftRef.Digest}), nil +} + +func buildSessionExecutionPromptArtifact(result artifacts.SessionExecutionTriggerAppendResult, _ sessionExecutionPlanAuthority) []byte { + text := strings.TrimSpace(result.Trigger.UserMessageContentText) + if text == "" { + text = strings.TrimSpace(result.Trigger.TriggerID) + } + return []byte(text) +} + +func buildSessionExecutionDraftTextArtifact(result artifacts.SessionExecutionTriggerAppendResult, authority sessionExecutionPlanAuthority) []byte { + identity := sessionExecutionDraftIdentity(result, "") + prompt := strings.TrimSpace(result.Trigger.UserMessageContentText) + if prompt == "" { + prompt = "No prompt content provided." + } + var b strings.Builder + switch strings.TrimSpace(authority.workflowOperation) { + case sessionWorkflowOperationChangeDraft: + b.WriteString("# ") + b.WriteString(identity) + b.WriteString("\n\n## Summary\n") + b.WriteString(prompt) + b.WriteString("\n\n## Scope\n- Drafted through the broker-owned trusted execution path.\n") + case sessionWorkflowOperationSpecDraft: + b.WriteString("# ") + b.WriteString(identity) + b.WriteString("\n\n## Goal\n") + b.WriteString(prompt) + b.WriteString("\n\n## Notes\n- Drafted through the broker-owned trusted execution path.\n") + default: + b.WriteString(prompt) + } + return []byte(b.String()) +} + +func (s *Service) persistSessionExecutionTypedDraftArtifact(runID string, result artifacts.SessionExecutionTriggerAppendResult, authority sessionExecutionPlanAuthority, sourcePromptDigest, draftArtifactDigest string) ([]byte, artifacts.ArtifactReference, error) { + payload, err := sessionExecutionTypedDraftArtifactPayload(result, authority, sourcePromptDigest, draftArtifactDigest) + if err != nil { + return nil, artifacts.ArtifactReference{}, err + } + stepID, artifactRef, err := sessionExecutionDraftArtifactBinding(authority) + if err != nil { + return nil, artifacts.ArtifactReference{}, err + } + ref, err := s.Put(artifacts.PutRequest{ + Payload: payload, + ContentType: "application/json", + DataClass: artifacts.DataClassSpecText, + ProvenanceReceiptHash: artifacts.DigestBytes(payload), + CreatedByRole: "brokerapi", + TrustedSource: true, + RunID: runID, + StepID: stepID, + }) + if err != nil { + return nil, artifacts.ArtifactReference{}, fmt.Errorf("persist %s artifact: %w", artifactRef, err) + } + return payload, ref, nil +} + +func sessionExecutionTypedDraftArtifactPayload(result artifacts.SessionExecutionTriggerAppendResult, authority sessionExecutionPlanAuthority, sourcePromptDigest, draftArtifactDigest string) ([]byte, error) { + if strings.TrimSpace(sourcePromptDigest) == "" { + return nil, fmt.Errorf("typed draft artifact requires source prompt digest") + } + if strings.TrimSpace(draftArtifactDigest) == "" { + return nil, fmt.Errorf("typed draft artifact requires artifact digest") + } + payload := map[string]any{ + "schema_id": strings.TrimSpace(authority.draftArtifactSchemaID), + "schema_version": "0.1.0", + "data_class": string(artifacts.DataClassSpecText), + "artifact_digest": digestIdentityObject(strings.TrimSpace(draftArtifactDigest)), + "source_prompt_identity_digest": digestIdentityObject(strings.TrimSpace(sourcePromptDigest)), + } + if digest := strings.TrimSpace(result.TurnExecution.BoundValidatedProjectSubstrateDigest); digest != "" { + payload["validated_project_substrate_digest"] = digestIdentityObject(digest) + } + switch strings.TrimSpace(authority.workflowOperation) { + case sessionWorkflowOperationChangeDraft: + payload["change_id"] = sessionExecutionDraftIdentity(result, "CHG-") + case sessionWorkflowOperationSpecDraft: + payload["spec_id"] = sessionExecutionDraftIdentity(result, "spec-") + default: + return nil, fmt.Errorf("typed draft artifact unsupported for workflow operation %q", strings.TrimSpace(authority.workflowOperation)) + } + raw, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal typed draft artifact payload: %w", err) + } + canonical, err := artifacts.CanonicalizeJSONBytes(raw) + if err != nil { + return nil, fmt.Errorf("canonicalize typed draft artifact payload: %w", err) + } + return canonical, nil +} + +func digestIdentityObject(digest string) map[string]any { + trimmed := strings.TrimSpace(strings.TrimPrefix(digest, "sha256:")) + return map[string]any{ + "hash_alg": "sha256", + "hash": trimmed, + } +} + +func sessionExecutionDraftTextArtifactBinding(authority sessionExecutionPlanAuthority) (string, string, error) { + switch strings.TrimSpace(authority.workflowOperation) { + case sessionWorkflowOperationChangeDraft: + return "session_execution/change_draft_text", "change_draft_text", nil + case sessionWorkflowOperationSpecDraft: + return "session_execution/spec_draft_text", "spec_draft_text", nil + default: + return "", "", fmt.Errorf("draft text binding unsupported for workflow operation %q", strings.TrimSpace(authority.workflowOperation)) + } +} + +func sessionExecutionPromptArtifactBinding(authority sessionExecutionPlanAuthority) (string, string, error) { + switch strings.TrimSpace(authority.workflowOperation) { + case sessionWorkflowOperationChangeDraft: + return "session_execution/change_draft_prompt", "change_draft_prompt", nil + case sessionWorkflowOperationSpecDraft: + return "session_execution/spec_draft_prompt", "spec_draft_prompt", nil + default: + return "", "", fmt.Errorf("draft prompt binding unsupported for workflow operation %q", strings.TrimSpace(authority.workflowOperation)) + } +} + +func sessionExecutionDraftArtifactBinding(authority sessionExecutionPlanAuthority) (string, string, error) { + switch strings.TrimSpace(authority.workflowOperation) { + case sessionWorkflowOperationChangeDraft: + return "session_execution/change_draft_artifact", "change_draft_artifact", nil + case sessionWorkflowOperationSpecDraft: + return "session_execution/spec_draft_artifact", "spec_draft_artifact", nil + default: + return "", "", fmt.Errorf("draft artifact binding unsupported for workflow operation %q", strings.TrimSpace(authority.workflowOperation)) + } +} + +func (s *Service) validateSessionExecutionTypedDraftArtifactPayload(schemaID string, payload []byte) error { + var schemaPath string + switch strings.TrimSpace(schemaID) { + case "runecode.protocol.v0.RuneContextChangeDraftArtifact": + schemaPath = "objects/RuneContextChangeDraftArtifact.schema.json" + case "runecode.protocol.v0.RuneContextSpecDraftArtifact": + schemaPath = "objects/RuneContextSpecDraftArtifact.schema.json" + default: + return fmt.Errorf("unsupported typed draft artifact schema %q", strings.TrimSpace(schemaID)) + } + if err := artifacts.ValidateObjectPayloadAgainstSchema(payload, schemaPath); err != nil { + return fmt.Errorf("validate typed draft artifact: %w", err) + } + return nil +} + +func sessionExecutionDraftIdentity(result artifacts.SessionExecutionTriggerAppendResult, prefix string) string { + seed := strings.TrimSpace(result.Trigger.UserMessageContentText) + if seed == "" { + seed = strings.TrimSpace(result.TurnExecution.TurnID) + } + normalized := nonDraftIdentityRunePattern.ReplaceAllString(strings.ToLower(seed), "-") + normalized = strings.Trim(normalized, "-._") + normalized = strings.ReplaceAll(normalized, "--", "-") + if normalized == "" { + normalized = sessionExecutionIdentifierToken(result.TurnExecution.TurnID) + } + if strings.TrimSpace(prefix) == "CHG-" { + if len(normalized) > 96 { + normalized = normalized[:96] + } + return "CHG-" + normalized + } + if len(normalized) > 123 { + normalized = normalized[:123] + } + return prefix + normalized +} + +func sessionExecutionDraftGateEvidenceDigests(result artifacts.SessionExecutionTriggerAppendResult) []string { + digests := make([]string, 0, len(result.TurnExecution.LinkedArtifactDigests)) + for _, digest := range result.TurnExecution.LinkedArtifactDigests { + trimmed := strings.TrimSpace(digest) + if trimmed == "" { + continue + } + digests = append(digests, trimmed) + } + return uniqueSortedStrings(digests) +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_draft_artifacts_persist.go b/internal/brokerapi/local_api_session_execution_trigger_draft_artifacts_persist.go new file mode 100644 index 00000000..6e719d80 --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_trigger_draft_artifacts_persist.go @@ -0,0 +1,49 @@ +package brokerapi + +import ( + "fmt" + + "github.com/runecode-ai/runecode/internal/artifacts" +) + +func (s *Service) persistSessionExecutionPromptArtifact(runID string, authority sessionExecutionPlanAuthority, payload []byte) (artifacts.ArtifactReference, error) { + stepID, _, err := sessionExecutionPromptArtifactBinding(authority) + if err != nil { + return artifacts.ArtifactReference{}, err + } + ref, err := s.Put(artifacts.PutRequest{ + Payload: payload, + ContentType: "text/plain; charset=utf-8", + DataClass: artifacts.DataClassSpecText, + ProvenanceReceiptHash: artifacts.DigestBytes(payload), + CreatedByRole: "brokerapi", + TrustedSource: true, + RunID: runID, + StepID: stepID, + }) + if err != nil { + return artifacts.ArtifactReference{}, fmt.Errorf("persist session execution source prompt: %w", err) + } + return ref, nil +} + +func (s *Service) persistSessionExecutionDraftTextArtifact(runID string, authority sessionExecutionPlanAuthority, payload []byte) (artifacts.ArtifactReference, error) { + stepID, _, err := sessionExecutionDraftTextArtifactBinding(authority) + if err != nil { + return artifacts.ArtifactReference{}, err + } + ref, err := s.Put(artifacts.PutRequest{ + Payload: payload, + ContentType: "text/markdown; charset=utf-8", + DataClass: artifacts.DataClassSpecText, + ProvenanceReceiptHash: artifacts.DigestBytes(payload), + CreatedByRole: "brokerapi", + TrustedSource: true, + RunID: runID, + StepID: stepID, + }) + if err != nil { + return artifacts.ArtifactReference{}, fmt.Errorf("persist session execution draft text: %w", err) + } + return ref, nil +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_draft_promote_apply.go b/internal/brokerapi/local_api_session_execution_trigger_draft_promote_apply.go new file mode 100644 index 00000000..0ce97896 --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_trigger_draft_promote_apply.go @@ -0,0 +1,152 @@ +package brokerapi + +import ( + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/artifacts" +) + +type sessionDraftPromoteResolvedInput struct { + draftArtifactDigest string + draftTextDigest string + draftSchemaID string + draftIdentity string + draftText []byte + targetRelativePath string + targetAbsolutePath string + appliedFileDigest string + sourcePromptDigest string + projectDigest string +} + +func (s *Service) applySessionExecutionDraftPromote(result artifacts.SessionExecutionTriggerAppendResult, authority sessionExecutionPlanAuthority) (string, error) { + resolved, err := s.resolveSessionDraftPromoteApplyInput(result, authority) + if err != nil { + return "", err + } + prepared, err := prepareDraftPromoteMutation(resolved) + if err != nil { + return "", err + } + approvalID := sessionDraftPromoteApplyApprovalIdentity(resolved) + if err := finalizeBrokerOwnedMutationWrites(prepared, func() error { + if err := s.recordSessionExecutionDraftPromoteApproval(result, authority, resolved, approvalID); err != nil { + return err + } + if err := s.appendDraftPromoteAuditEvent(result, authority, resolved, approvalID); err != nil { + return fmt.Errorf("append draft promote/apply audit event: %w", err) + } + return nil + }); err != nil { + return "", err + } + return approvalID, nil +} + +func prepareDraftPromoteMutation(resolved sessionDraftPromoteResolvedInput) ([]brokerOwnedPreparedMutationWrite, error) { + return prepareBrokerOwnedMutationWrites([]brokerOwnedMutationWriteIntent{{ + targetAbsolutePath: resolved.targetAbsolutePath, + targetRelativePath: resolved.targetRelativePath, + contents: resolved.draftText, + expectedDigest: resolved.appliedFileDigest, + mode: 0o644, + }}) +} + +func (s *Service) appendDraftPromoteAuditEvent(result artifacts.SessionExecutionTriggerAppendResult, authority sessionExecutionPlanAuthority, resolved sessionDraftPromoteResolvedInput, approvalID string) error { + return s.AppendTrustedAuditEvent("runecontext_draft_promote_apply", "brokerapi", map[string]interface{}{ + "run_id": strings.TrimSpace(authority.runID), + "plan_id": strings.TrimSpace(authority.planID), + "workflow_operation": strings.TrimSpace(authority.workflowOperation), + "draft_artifact_digest": strings.TrimSpace(resolved.draftArtifactDigest), + "draft_text_digest": strings.TrimSpace(resolved.draftTextDigest), + "draft_schema_id": strings.TrimSpace(resolved.draftSchemaID), + "draft_identity": strings.TrimSpace(resolved.draftIdentity), + "source_prompt_digest": strings.TrimSpace(resolved.sourcePromptDigest), + "project_digest": strings.TrimSpace(resolved.projectDigest), + "target_relative_path": strings.TrimSpace(resolved.targetRelativePath), + "applied_file_digest": strings.TrimSpace(resolved.appliedFileDigest), + "approval_id": strings.TrimSpace(approvalID), + "trigger_id": strings.TrimSpace(result.Trigger.TriggerID), + "turn_id": strings.TrimSpace(result.TurnExecution.TurnID), + }) +} + +func sessionDraftPromoteApplyApprovalIdentity(resolved sessionDraftPromoteResolvedInput) string { + return shaDigestIdentity(strings.TrimSpace(resolved.draftArtifactDigest) + "\n" + strings.TrimSpace(resolved.targetRelativePath)) +} + +func (s *Service) recordSessionExecutionDraftPromoteApproval(result artifacts.SessionExecutionTriggerAppendResult, authority sessionExecutionPlanAuthority, resolved sessionDraftPromoteResolvedInput, approvalID string) error { + actionHash := sessionDraftPromoteActionHash(resolved) + priorRefs := stringSetFromSlice(s.PolicyDecisionRefsForRun(strings.TrimSpace(authority.runID))) + if err := s.RecordPolicyDecision(strings.TrimSpace(authority.runID), "", draftPromotePolicyDecision(authority, resolved, actionHash)); err != nil { + return fmt.Errorf("record draft promote/apply policy decision: %w", err) + } + decisionHash, err := recordedPolicyDecisionHashForRunAndAction(s, strings.TrimSpace(authority.runID), actionHash, priorRefs) + if err != nil { + return fmt.Errorf("locate draft promote/apply policy decision hash: %w", err) + } + record := draftPromoteApprovalRecord(authority, resolved, approvalID, actionHash, decisionHash, s.currentTimestamp()) + if err := s.RecordApproval(record); err != nil { + return fmt.Errorf("record draft promote/apply approval: %w", err) + } + return nil +} + +func sessionDraftPromoteActionHash(resolved sessionDraftPromoteResolvedInput) string { + return shaDigestIdentity(strings.TrimSpace(resolved.targetRelativePath) + "\n" + strings.TrimSpace(resolved.appliedFileDigest) + "\n" + strings.TrimSpace(resolved.draftArtifactDigest)) +} + +func recordedPolicyDecisionHashForRunAndAction(s *Service, runID, actionHash string, priorRefs map[string]struct{}) (string, error) { + newMatches, existingMatches := policyDecisionMatchesForRunAndAction(s, runID, actionHash, priorRefs) + return selectRecordedPolicyDecisionHash(newMatches, existingMatches) +} + +func policyDecisionMatchesForRunAndAction(s *Service, runID, actionHash string, priorRefs map[string]struct{}) ([]string, []string) { + newMatches := []string{} + existingMatches := []string{} + for _, digest := range s.PolicyDecisionRefsForRun(strings.TrimSpace(runID)) { + record, ok := s.PolicyDecisionGet(strings.TrimSpace(digest)) + if !ok { + continue + } + if strings.TrimSpace(record.ActionRequestHash) != strings.TrimSpace(actionHash) { + continue + } + trimmed := strings.TrimSpace(record.Digest) + if _, existed := priorRefs[trimmed]; existed { + existingMatches = append(existingMatches, trimmed) + continue + } + newMatches = append(newMatches, trimmed) + } + return newMatches, existingMatches +} + +func selectRecordedPolicyDecisionHash(newMatches, existingMatches []string) (string, error) { + if len(newMatches) == 1 { + return newMatches[0], nil + } + if len(newMatches) > 1 { + return "", fmt.Errorf("multiple newly recorded decisions matched action hash") + } + if len(existingMatches) == 1 { + return existingMatches[0], nil + } + if len(existingMatches) > 1 { + return "", fmt.Errorf("multiple existing decisions matched action hash") + } + return "", fmt.Errorf("decision not found") +} + +func stringSetFromSlice(values []string) map[string]struct{} { + out := map[string]struct{}{} + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + out[trimmed] = struct{}{} + } + } + return out +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_draft_promote_apply_integrity_test.go b/internal/brokerapi/local_api_session_execution_trigger_draft_promote_apply_integrity_test.go new file mode 100644 index 00000000..74bcbcee --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_trigger_draft_promote_apply_integrity_test.go @@ -0,0 +1,60 @@ +package brokerapi + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/runecode-ai/runecode/internal/artifacts" +) + +func TestDraftPromoteApplyRejectsTamperedBoundDraftArtifactBlob(t *testing.T) { + _, s := newSessionExecutionTriggerWorkflowService(t, "run-draft-promote-tamper", "sess-draft-promote-tamper") + draft := runChangeDraftForPromoteApply(t, s, "sess-draft-promote-tamper", "req-draft-promote-tamper-draft", "Draft promote tamper") + tamperArtifactBlobForTest(t, s, draft.digest, []byte(`{"tampered":true}`)) + _, err := s.loadDraftPromoteDecodedArtifact(artifacts.SessionWorkflowPackBoundInputArtifactDurableState{ArtifactRef: "change_draft_artifact", ArtifactDigest: draft.digest}) + if err == nil || !strings.Contains(err.Error(), "digest drift") { + t.Fatalf("loadDraftPromoteDecodedArtifact error = %v, want digest drift", err) + } +} + +func TestDraftPromoteApplyRejectsTamperedDraftTextArtifactBlob(t *testing.T) { + _, s := newSessionExecutionTriggerWorkflowService(t, "run-draft-text-tamper", "sess-draft-text-tamper") + draft := runChangeDraftForPromoteApply(t, s, "sess-draft-text-tamper", "req-draft-text-tamper-draft", "Draft text tamper") + payload, err := s.readArtifactPayloadVerified(draft.digest) + if err != nil { + t.Fatalf("readArtifactPayloadVerified returned error: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + draftTextDigest := draftPromoteDigestObjectValueFromMap(decoded, "artifact_digest") + tamperArtifactBlobForTest(t, s, draftTextDigest, []byte("tampered text")) + _, _, err = s.loadDraftPromoteText(decoded, draft.digest) + if err == nil || !strings.Contains(err.Error(), "digest drift") { + t.Fatalf("loadDraftPromoteText error = %v, want digest drift", err) + } +} + +func TestValidateDraftPromoteProjectDigestRequiresArtifactBindingWhenBound(t *testing.T) { + bound := "sha256:" + strings.Repeat("a", 64) + if err := validateDraftPromoteProjectDigest("", bound); err == nil { + t.Fatal("validateDraftPromoteProjectDigest expected missing artifact binding error") + } + if err := validateDraftPromoteProjectDigest(bound, bound); err != nil { + t.Fatalf("validateDraftPromoteProjectDigest returned error: %v", err) + } +} + +func tamperArtifactBlobForTest(t *testing.T, s *Service, digest string, payload []byte) { + t.Helper() + record, err := s.store.Head(digest) + if err != nil { + t.Fatalf("Head returned error: %v", err) + } + if err := os.WriteFile(record.BlobPath, payload, 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_draft_promote_apply_policy.go b/internal/brokerapi/local_api_session_execution_trigger_draft_promote_apply_policy.go new file mode 100644 index 00000000..7feadf3d --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_trigger_draft_promote_apply_policy.go @@ -0,0 +1,87 @@ +package brokerapi + +import ( + "strings" + "time" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/policyengine" +) + +func draftPromotePolicyDecision(authority sessionExecutionPlanAuthority, resolved sessionDraftPromoteResolvedInput, actionHash string) policyengine.PolicyDecision { + policyInputHashes := uniqueSortedStrings([]string{strings.TrimSpace(resolved.projectDigest)}) + relevantArtifactHashes := uniqueSortedStrings([]string{ + strings.TrimSpace(resolved.draftArtifactDigest), + strings.TrimSpace(resolved.draftTextDigest), + }) + return policyengine.PolicyDecision{ + SchemaID: "runecode.protocol.v0.PolicyDecision", + SchemaVersion: "0.3.0", + DecisionOutcome: policyengine.DecisionRequireHumanApproval, + PolicyReasonCode: "approval_required", + ManifestHash: strings.TrimSpace(resolved.draftArtifactDigest), + ActionRequestHash: actionHash, + PolicyInputHashes: policyInputHashes, + RelevantArtifactHashes: relevantArtifactHashes, + DetailsSchemaID: "runecode.protocol.details.policy.evaluation.v0", + Details: map[string]any{ + "precedence": "approval_profile_moderate", + "checkpoint_model": "workspace_write", + }, + RequiredApprovalSchemaID: "runecode.protocol.details.policy.required_approval.out_of_workspace_write.v0", + RequiredApproval: map[string]any{ + "approval_trigger_code": "out_of_workspace_write", + "approval_assurance_level": approvalDefaultAssuranceLevel, + "presence_mode": approvalDefaultPresenceMode, + "changes_if_approved": "Apply reviewed RuneContext draft into canonical project files.", + "approval_ttl_seconds": 1800, + "scope": draftPromoteApprovalScope(authority), + "related_hashes": map[string]any{ + "manifest_hash": strings.TrimSpace(resolved.draftArtifactDigest), + "action_request_hash": actionHash, + "policy_input_hashes": policyInputHashes, + "relevant_artifact_hashes": relevantArtifactHashes, + }, + }, + } +} + +func draftPromoteApprovalScope(authority sessionExecutionPlanAuthority) map[string]any { + return map[string]any{ + "schema_id": "runecode.protocol.v0.ApprovalBoundScope", + "schema_version": "0.1.0", + "workspace_id": workspaceIDForRun(authority.runID), + "run_id": strings.TrimSpace(authority.runID), + "stage_id": strings.TrimSpace(authority.stageID), + "step_id": strings.TrimSpace(authority.stepID), + "role_instance_id": strings.TrimSpace(authority.roleInstanceID), + "action_kind": policyengine.ActionKindWorkspaceWrite, + } +} + +func draftPromoteApprovalRecord(authority sessionExecutionPlanAuthority, resolved sessionDraftPromoteResolvedInput, approvalID, actionHash, decisionHash string, now time.Time) artifacts.ApprovalRecord { + return artifacts.ApprovalRecord{ + ApprovalID: strings.TrimSpace(approvalID), + Status: "consumed", + WorkspaceID: workspaceIDForRun(authority.runID), + RunID: strings.TrimSpace(authority.runID), + StageID: strings.TrimSpace(authority.stageID), + StepID: strings.TrimSpace(authority.stepID), + RoleInstanceID: strings.TrimSpace(authority.roleInstanceID), + ActionKind: policyengine.ActionKindWorkspaceWrite, + RequestedAt: now, + DecidedAt: &now, + ConsumedAt: &now, + ApprovalTriggerCode: "out_of_workspace_write", + ChangesIfApproved: "Apply reviewed RuneContext draft into canonical project files.", + ApprovalAssuranceLevel: approvalDefaultAssuranceLevel, + PresenceMode: approvalDefaultPresenceMode, + PolicyDecisionHash: strings.TrimSpace(decisionHash), + ManifestHash: strings.TrimSpace(resolved.draftArtifactDigest), + ActionRequestHash: actionHash, + RelevantArtifactHashes: uniqueSortedStrings([]string{resolved.draftArtifactDigest, resolved.draftTextDigest}), + RequestDigest: strings.TrimSpace(approvalID), + DecisionDigest: strings.TrimSpace(decisionHash), + SourceDigest: strings.TrimSpace(resolved.draftArtifactDigest), + } +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_draft_promote_apply_resolve.go b/internal/brokerapi/local_api_session_execution_trigger_draft_promote_apply_resolve.go new file mode 100644 index 00000000..8287428d --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_trigger_draft_promote_apply_resolve.go @@ -0,0 +1,232 @@ +package brokerapi + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/projectsubstrate" +) + +func (s *Service) resolveSessionDraftPromoteApplyInput(result artifacts.SessionExecutionTriggerAppendResult, authority sessionExecutionPlanAuthority) (sessionDraftPromoteResolvedInput, error) { + binding, err := resolveSingleDraftPromoteBinding(result.TurnExecution.WorkflowRouting.BoundInputArtifacts) + if err != nil { + return sessionDraftPromoteResolvedInput{}, err + } + repoRoot, err := draftPromoteRepositoryRoot(s) + if err != nil { + return sessionDraftPromoteResolvedInput{}, err + } + decoded, err := s.loadDraftPromoteDecodedArtifact(binding) + if err != nil { + return sessionDraftPromoteResolvedInput{}, err + } + draftTextDigest, draftText, err := s.loadDraftPromoteText(decoded, binding.ArtifactDigest) + if err != nil { + return sessionDraftPromoteResolvedInput{}, err + } + draftIdentity, targetRelativePath, err := sessionDraftPromoteIdentityAndPath(binding.ArtifactRef, decoded) + if err != nil { + return sessionDraftPromoteResolvedInput{}, err + } + targetRelativePath, projectDigest, targetAbsolutePath, err := s.resolveDraftPromoteTarget(binding.ArtifactRef, authority, decoded, result.TurnExecution.BoundValidatedProjectSubstrateDigest, repoRoot) + if err != nil { + return sessionDraftPromoteResolvedInput{}, err + } + return sessionDraftPromoteResolvedInput{ + draftArtifactDigest: strings.TrimSpace(binding.ArtifactDigest), + draftTextDigest: strings.TrimSpace(draftTextDigest), + draftSchemaID: strings.TrimSpace(draftPromoteStringValueFromMap(decoded, "schema_id")), + draftIdentity: strings.TrimSpace(draftIdentity), + draftText: append([]byte(nil), draftText...), + targetRelativePath: targetRelativePath, + targetAbsolutePath: targetAbsolutePath, + appliedFileDigest: artifacts.DigestBytes(draftText), + sourcePromptDigest: draftPromoteDigestObjectValueFromMap(decoded, "source_prompt_identity_digest"), + projectDigest: projectDigest, + }, nil +} + +func (s *Service) resolveDraftPromoteTarget(artifactRef string, authority sessionExecutionPlanAuthority, decoded map[string]any, boundDigest, repoRoot string) (string, string, string, error) { + _, targetRelativePath, err := sessionDraftPromoteIdentityAndPath(artifactRef, decoded) + if err != nil { + return "", "", "", err + } + if err := validateDraftPromoteTargetPath(authority, targetRelativePath); err != nil { + return "", "", "", err + } + projectDigest := draftPromoteDigestObjectValueFromMap(decoded, "validated_project_substrate_digest") + if err := validateDraftPromoteProjectDigest(projectDigest, boundDigest); err != nil { + return "", "", "", err + } + targetAbsolutePath, err := brokerOwnedDraftPromoteTargetPath(repoRoot, targetRelativePath) + if err != nil { + return "", "", "", err + } + return targetRelativePath, projectDigest, targetAbsolutePath, nil +} + +func draftPromoteRepositoryRoot(s *Service) (string, error) { + repoRoot := strings.TrimSpace(s.projectSubstrate.RepositoryRoot) + if repoRoot == "" { + repoRoot = strings.TrimSpace(s.apiConfig.RepositoryRoot) + } + if repoRoot == "" { + return "", fmt.Errorf("repository root is required for draft promote/apply") + } + return repoRoot, nil +} + +func (s *Service) loadDraftPromoteDecodedArtifact(binding artifacts.SessionWorkflowPackBoundInputArtifactDurableState) (map[string]any, error) { + payload, err := s.readArtifactPayloadVerified(binding.ArtifactDigest) + if err != nil { + return nil, fmt.Errorf("read bound draft artifact %q: %w", binding.ArtifactDigest, err) + } + if err := validateDraftPromoteArtifactPayload(binding.ArtifactRef, payload); err != nil { + return nil, err + } + var decoded map[string]any + if err := json.Unmarshal(payload, &decoded); err != nil { + return nil, fmt.Errorf("decode bound draft artifact %q: %w", binding.ArtifactDigest, err) + } + return decoded, nil +} + +func (s *Service) loadDraftPromoteText(decoded map[string]any, artifactDigest string) (string, []byte, error) { + draftTextDigest := draftPromoteDigestObjectValueFromMap(decoded, "artifact_digest") + if strings.TrimSpace(draftTextDigest) == "" { + return "", nil, fmt.Errorf("bound draft artifact %q missing artifact_digest", artifactDigest) + } + draftText, err := s.readArtifactPayloadVerified(draftTextDigest) + if err != nil { + return "", nil, fmt.Errorf("read draft text artifact %q: %w", draftTextDigest, err) + } + return draftTextDigest, draftText, nil +} + +func validateDraftPromoteProjectDigest(projectDigest, boundDigest string) error { + if strings.TrimSpace(boundDigest) != "" && strings.TrimSpace(projectDigest) == "" { + return fmt.Errorf("draft promote/apply artifact missing validated_project_substrate_digest") + } + if strings.TrimSpace(projectDigest) == "" { + return nil + } + if strings.TrimSpace(projectDigest) != strings.TrimSpace(boundDigest) { + return fmt.Errorf("draft promote/apply validated_project_substrate_digest drift detected") + } + return nil +} + +func validateDraftPromoteArtifactPayload(artifactRef string, payload []byte) error { + schemaPath, err := draftPromoteSchemaPathForArtifactRef(artifactRef) + if err != nil { + return err + } + if err := artifacts.ValidateObjectPayloadAgainstSchema(payload, schemaPath); err != nil { + return fmt.Errorf("validate draft promote/apply input artifact: %w", err) + } + return nil +} + +func draftPromoteSchemaPathForArtifactRef(artifactRef string) (string, error) { + switch strings.TrimSpace(artifactRef) { + case "change_draft_artifact": + return "objects/RuneContextChangeDraftArtifact.schema.json", nil + case "spec_draft_artifact": + return "objects/RuneContextSpecDraftArtifact.schema.json", nil + default: + return "", fmt.Errorf("draft promote/apply bound artifact ref %q is unsupported", strings.TrimSpace(artifactRef)) + } +} + +func resolveSingleDraftPromoteBinding(bindings []artifacts.SessionWorkflowPackBoundInputArtifactDurableState) (artifacts.SessionWorkflowPackBoundInputArtifactDurableState, error) { + if len(bindings) != 1 { + return artifacts.SessionWorkflowPackBoundInputArtifactDurableState{}, fmt.Errorf("draft promote/apply requires exactly one bound draft artifact") + } + binding := bindings[0] + if strings.TrimSpace(binding.ArtifactDigest) == "" { + return artifacts.SessionWorkflowPackBoundInputArtifactDurableState{}, fmt.Errorf("draft promote/apply bound draft artifact digest is required") + } + if _, err := draftPromoteSchemaPathForArtifactRef(binding.ArtifactRef); err != nil { + return artifacts.SessionWorkflowPackBoundInputArtifactDurableState{}, err + } + return binding, nil +} + +func sessionDraftPromoteIdentityAndPath(artifactRef string, decoded map[string]any) (string, string, error) { + switch strings.TrimSpace(artifactRef) { + case "change_draft_artifact": + return sessionDraftPromoteChangeIdentityAndPath(decoded) + case "spec_draft_artifact": + return sessionDraftPromoteSpecIdentityAndPath(decoded) + default: + return "", "", fmt.Errorf("draft promote/apply bound artifact ref %q is unsupported", strings.TrimSpace(artifactRef)) + } +} + +func sessionDraftPromoteChangeIdentityAndPath(decoded map[string]any) (string, string, error) { + changeID := strings.TrimSpace(draftPromoteStringValueFromMap(decoded, "change_id")) + if changeID == "" { + return "", "", fmt.Errorf("change draft artifact missing change_id") + } + return changeID, filepath.ToSlash(filepath.Join(projectsubstrate.CanonicalChangesPath, changeID, "proposal.md")), nil +} + +func sessionDraftPromoteSpecIdentityAndPath(decoded map[string]any) (string, string, error) { + specID := strings.TrimSpace(draftPromoteStringValueFromMap(decoded, "spec_id")) + if specID == "" { + return "", "", fmt.Errorf("spec draft artifact missing spec_id") + } + return specID, filepath.ToSlash(filepath.Join(projectsubstrate.CanonicalSpecsPath, specID+".md")), nil +} + +func validateDraftPromoteTargetPath(authority sessionExecutionPlanAuthority, targetRelativePath string) error { + entry, err := builtInCatalogEntryForWorkflowOperation(authority.workflowOperation) + if err != nil { + return err + } + target, err := normalizeBrokerOwnedRelativeTargetPath(targetRelativePath) + if err != nil { + return err + } + if target == "" { + return fmt.Errorf("draft promote/apply target path is required") + } + for _, allowed := range entry.WritableRuneContextPath { + prefix, err := normalizeBrokerOwnedRelativeTargetPath(allowed) + if err != nil { + return err + } + if pathWithinAllowedPrefix(target, prefix) { + return nil + } + } + return fmt.Errorf("draft promote/apply target path %q is outside writable RuneContext scope", target) +} + +func draftPromoteDigestObjectValueFromMap(in map[string]any, key string) string { + raw, ok := in[key] + if !ok { + return "" + } + value, ok := raw.(map[string]any) + if !ok { + return "" + } + hash, _ := value["hash"].(string) + if strings.TrimSpace(hash) == "" { + return "" + } + return "sha256:" + strings.TrimSpace(hash) +} + +func draftPromoteStringValueFromMap(in map[string]any, key string) string { + raw, ok := in[key] + if !ok { + return "" + } + value, _ := raw.(string) + return value +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_helpers_test.go b/internal/brokerapi/local_api_session_execution_trigger_helpers_test.go index 52508e08..8aa8630e 100644 --- a/internal/brokerapi/local_api_session_execution_trigger_helpers_test.go +++ b/internal/brokerapi/local_api_session_execution_trigger_helpers_test.go @@ -1,6 +1,11 @@ package brokerapi import ( + "encoding/json" + "io" + "os" + "path/filepath" + "strings" "testing" "github.com/runecode-ai/runecode/internal/artifacts" @@ -81,3 +86,201 @@ func assertSessionExecutionContinueBlocked(t *testing.T, errResp *ErrorResponse, t.Fatalf("error code = %q, want %q", errResp.Error.Code, wantCode) } } + +func requireSessionExecutionLinkedArtifactByStepAndSchema(t *testing.T, s *Service, runID, stepID, schemaID, expectedText string) map[string]any { + t.Helper() + record := requireRunArtifactRecordByStep(t, s, runID, stepID) + payload := mustArtifactPayload(t, s, record.Reference.Digest) + if schemaID == "" { + assertArtifactTextPayload(t, stepID, payload, expectedText) + return nil + } + decoded := mustDecodeArtifactJSON(t, stepID, payload) + assertTypedArtifactSchemaAndBindings(t, stepID, decoded, schemaID) + return decoded +} + +func requireRunArtifactRecordByStep(t *testing.T, s *Service, runID, stepID string) artifacts.ArtifactRecord { + t.Helper() + for _, record := range s.List() { + if strings.TrimSpace(record.RunID) == strings.TrimSpace(runID) && strings.TrimSpace(record.StepID) == strings.TrimSpace(stepID) { + return record + } + } + t.Fatalf("artifact for run=%s step=%s not found", runID, stepID) + return artifacts.ArtifactRecord{} +} + +func mustArtifactPayload(t *testing.T, s *Service, digest string) []byte { + t.Helper() + reader, err := s.Get(digest) + if err != nil { + t.Fatalf("Get(%q) returned error: %v", digest, err) + } + payload, err := io.ReadAll(reader) + _ = reader.Close() + if err != nil { + t.Fatalf("ReadAll(%q) returned error: %v", digest, err) + } + return payload +} + +func assertArtifactTextPayload(t *testing.T, stepID string, payload []byte, expectedText string) { + t.Helper() + if got := strings.TrimSpace(string(payload)); got != strings.TrimSpace(expectedText) { + t.Fatalf("artifact %s payload = %q, want %q", stepID, got, expectedText) + } +} + +func mustDecodeArtifactJSON(t *testing.T, stepID string, payload []byte) map[string]any { + t.Helper() + var decoded map[string]any + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("Unmarshal(%s) returned error: %v", stepID, err) + } + return decoded +} + +func assertTypedArtifactSchemaAndBindings(t *testing.T, stepID string, decoded map[string]any, schemaID string) { + t.Helper() + if got := strings.TrimSpace(stringValueFromMap(decoded, "schema_id")); got != schemaID { + t.Fatalf("artifact %s schema_id = %q, want %q", stepID, got, schemaID) + } + if digest := digestObjectValueFromMap(decoded, "artifact_digest"); digest == "" { + t.Fatalf("artifact %s missing artifact_digest: %+v", stepID, decoded) + } + if digest := digestObjectValueFromMap(decoded, "source_prompt_identity_digest"); digest == "" { + t.Fatalf("artifact %s missing source_prompt_identity_digest: %+v", stepID, decoded) + } +} + +func digestObjectValueFromMap(in map[string]any, key string) string { + raw, ok := in[key] + if !ok { + return "" + } + value, ok := raw.(map[string]any) + if !ok { + return "" + } + hash, _ := value["hash"].(string) + if strings.TrimSpace(hash) == "" { + return "" + } + return "sha256:" + strings.TrimSpace(hash) +} + +func stringValueFromMap(in map[string]any, key string) string { + raw, ok := in[key] + if !ok { + return "" + } + value, _ := raw.(string) + return value +} + +func requireFileContents(t *testing.T, root, relativePath, want string) { + t.Helper() + payload, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(relativePath))) + if err != nil { + t.Fatalf("ReadFile(%s) returned error: %v", relativePath, err) + } + if got := string(payload); got != want { + t.Fatalf("file %s contents mismatch\nwant:\n%s\n\ngot:\n%s", relativePath, want, got) + } +} + +func putApprovedImplementationMutationArtifactForTest(t *testing.T, s *Service, payload map[string]any) string { + t.Helper() + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + canonical, err := artifacts.CanonicalizeJSONBytes(raw) + if err != nil { + t.Fatalf("CanonicalizeJSONBytes returned error: %v", err) + } + ref, err := s.Put(artifacts.PutRequest{Payload: canonical, ContentType: "application/json", DataClass: artifacts.DataClassSpecText, ProvenanceReceiptHash: artifacts.DigestBytes(canonical), CreatedByRole: "test", TrustedSource: true}) + if err != nil { + t.Fatalf("Put returned error: %v", err) + } + if _, _, _, err := s.approvedImplementationWriteIntent(canonical); err != nil { + t.Fatalf("approvedImplementationWriteIntent returned error: %v", err) + } + return ref.Digest +} + +func approvedImplementationInputSetFixture(t *testing.T, s *Service, approvedDigests, workspaceDigests, metadataDigests []string) map[string]any { + t.Helper() + entry := approvedImplementationCatalogEntry() + if entry.WorkflowID == "" { + t.Fatal("approved implementation catalog entry missing") + } + payload := map[string]any{ + "schema_id": "runecode.protocol.v0.RuneContextApprovedImplementationInputSet", + "schema_version": "0.1.0", + "approved_input_digests": digestObjects(approvedDigests), + "workflow_definition_hash": digestObject(entry.WorkflowDefinitionHash), + "process_definition_hash": digestObject(entry.ProcessDefinitionHash), + "approval_profile": "moderate", + "autonomy_posture": "operator_guided", + "validated_project_substrate_digest": digestObject(s.projectSubstrate.Snapshot.ValidatedSnapshotDigest), + "project_substrate_snapshot_digest": digestObject(s.projectSubstrate.Snapshot.SnapshotDigest), + "control_input_digest": digestObject(artifacts.DigestBytes([]byte("approved-implementation-control"))), + "repo_identity_digest": digestObject(artifacts.DigestBytes([]byte("approved-implementation-repo"))), + "repo_state_identity_digest": digestObject(artifacts.DigestBytes([]byte("approved-implementation-state"))), + } + if len(workspaceDigests) > 0 { + payload["workspace_mutation_digests"] = digestObjects(workspaceDigests) + } + if len(metadataDigests) > 0 { + payload["lifecycle_metadata_mutation_digests"] = digestObjects(metadataDigests) + } + setApprovedImplementationInputSetDigest(t, payload) + return payload +} + +func putApprovedImplementationInputSetForTest(t *testing.T, s *Service, payload map[string]any) string { + t.Helper() + setApprovedImplementationInputSetDigest(t, payload) + return putApprovedImplementationInputSetArtifactForTest(t, s, payload) +} + +func putApprovedImplementationInputSetArtifactForTest(t *testing.T, s *Service, payload map[string]any) string { + t.Helper() + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + canonical, err := artifacts.CanonicalizeJSONBytes(raw) + if err != nil { + t.Fatalf("CanonicalizeJSONBytes returned error: %v", err) + } + ref, err := s.Put(artifacts.PutRequest{Payload: canonical, ContentType: "application/json", DataClass: artifacts.DataClassSpecText, ProvenanceReceiptHash: artifacts.DigestBytes(canonical), CreatedByRole: "test", TrustedSource: true}) + if err != nil { + t.Fatalf("Put returned error: %v", err) + } + return ref.Digest +} + +func setApprovedImplementationInputSetDigest(t *testing.T, payload map[string]any) { + t.Helper() + delete(payload, "input_set_digest") + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + canonical, err := artifacts.CanonicalizeJSONBytes(raw) + if err != nil { + t.Fatalf("CanonicalizeJSONBytes returned error: %v", err) + } + payload["input_set_digest"] = digestObject(artifacts.DigestBytes(canonical)) +} + +func digestObjects(digests []string) []any { + out := make([]any, 0, len(digests)) + for _, digest := range digests { + out = append(out, digestObject(digest)) + } + return out +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_ops.go b/internal/brokerapi/local_api_session_execution_trigger_ops.go index 5019d8eb..942166eb 100644 --- a/internal/brokerapi/local_api_session_execution_trigger_ops.go +++ b/internal/brokerapi/local_api_session_execution_trigger_ops.go @@ -43,7 +43,7 @@ func (s *Service) HandleSessionExecutionTrigger(ctx context.Context, req Session return SessionExecutionTriggerResponse{}, errResp } if created || req.RequestedOperation == "continue" { - if err := s.reconcileSessionExecutionTriggerSideEffects(requestID, session, req, resp); err != nil { + if err := s.reconcileSessionExecutionTriggerSideEffects(requestCtx, requestID, session, req, resp); err != nil { errOut := s.makeError(requestID, "broker_storage_write_failed", "storage", false, err.Error()) return SessionExecutionTriggerResponse{}, &errOut } diff --git a/internal/brokerapi/local_api_session_execution_trigger_ops_test.go b/internal/brokerapi/local_api_session_execution_trigger_ops_test.go index ef04f298..0f3d1c6b 100644 --- a/internal/brokerapi/local_api_session_execution_trigger_ops_test.go +++ b/internal/brokerapi/local_api_session_execution_trigger_ops_test.go @@ -6,10 +6,7 @@ import ( "testing" "github.com/runecode-ai/runecode/internal/artifacts" - "github.com/runecode-ai/runecode/internal/launcherbackend" - "github.com/runecode-ai/runecode/internal/policyengine" "github.com/runecode-ai/runecode/internal/projectsubstrate" - "github.com/runecode-ai/runecode/internal/trustpolicy" ) func TestSessionExecutionTriggerReturnsTypedAckAndSupportsIdempotency(t *testing.T) { @@ -74,6 +71,36 @@ func TestSessionExecutionTriggerFailsClosedWhenProjectSubstrateMissing(t *testin } } +func TestSessionExecutionTriggerFailsClosedForBlockedProjectSubstratePostures(t *testing.T) { + testCases := []struct { + name string + posture string + reasonCodes []string + }{ + {name: "invalid", posture: projectsubstrate.CompatibilityPostureInvalid, reasonCodes: []string{"project_substrate_invalid"}}, + {name: "non-verified", posture: projectsubstrate.CompatibilityPostureNonVerified, reasonCodes: []string{"project_substrate_non_verified"}}, + {name: "unsupported-too-old", posture: projectsubstrate.CompatibilityPostureUnsupportedTooOld, reasonCodes: []string{"project_substrate_unsupported_too_old"}}, + {name: "unsupported-too-new", posture: projectsubstrate.CompatibilityPostureUnsupportedTooNew, reasonCodes: []string{"project_substrate_unsupported_too_new"}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + seedSessionRuntimeFactsForOpsTest(t, s, "run-session-trigger-blocked-"+tc.name, "sess-trigger-blocked-"+tc.name) + s.discoverProjectSubstrateFn = func() (projectsubstrate.DiscoveryResult, error) { + return projectsubstrate.DiscoveryResult{Compatibility: projectsubstrate.CompatibilityAssessment{Posture: tc.posture, NormalOperationAllowed: false, BlockedReasonCodes: tc.reasonCodes}}, nil + } + _, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-blocked-" + tc.name, SessionID: "sess-trigger-blocked-" + tc.name, TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: defaultWorkflowRoutingForTriggerTests(), UserMessageContentText: "hello"}, RequestContext{}) + if errResp == nil { + t.Fatalf("HandleSessionExecutionTrigger expected blocked posture error for %s", tc.posture) + } + if errResp.Error.Code != "project_substrate_operation_blocked" { + t.Fatalf("error code = %q, want project_substrate_operation_blocked", errResp.Error.Code) + } + }) + } +} + func TestSessionExecutionTriggerAllowsDistinctWaitingVocabularyAndControlSeparation(t *testing.T) { s := newBrokerAPIServiceForTests(t, APIConfig{}) seedSessionRuntimeFactsForOpsTest(t, s, "run-session-trigger-controls", "sess-trigger-controls") @@ -113,6 +140,16 @@ func TestSessionExecutionTriggerStartCreatesSessionAndBrokerOwnedRunBinding(t *t if exec.PrimaryRunID != getResp.Session.Summary.Identity.CreatedByRunID { t.Fatalf("primary_run_id = %q, want created_by_run_id %q", exec.PrimaryRunID, getResp.Session.Summary.Identity.CreatedByRunID) } + authority, ok, err := s.ActiveRunPlanAuthority(exec.PrimaryRunID) + if err != nil { + t.Fatalf("ActiveRunPlanAuthority returned error: %v", err) + } + if !ok { + t.Fatal("active trusted run plan authority missing for session execution run") + } + if strings.TrimSpace(authority.PlanID) == "" || strings.TrimSpace(authority.RunPlanDigest) == "" { + t.Fatalf("active run plan authority invalid: %+v", authority) + } } func TestSessionExecutionTriggerFailsClosedOnOverlappingMutationBearingStarts(t *testing.T) { @@ -172,270 +209,132 @@ func TestSessionExecutionTriggerAllowsDraftRoutingOperations(t *testing.T) { } } -func TestSessionExecutionTriggerRejectsMutationBearingDraftRouting(t *testing.T) { - s := newBrokerAPIServiceForTests(t, APIConfig{}) - seedSessionRuntimeFactsForOpsTest(t, s, "run-session-trigger-draft-mutation", "sess-trigger-draft-mutation") - _, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-draft-mutation", SessionID: "sess-trigger-draft-mutation", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: "change_draft", BoundInputArtifacts: []SessionWorkflowPackBoundInputArtifact{{ArtifactRef: "change_draft_artifact", ArtifactDigest: digestForBrokerTest("x")}}}, UserMessageContentText: "hello"}, RequestContext{}) - assertSessionExecutionContinueBlocked(t, errResp, "broker_validation_schema_invalid") -} - -func TestSessionExecutionTriggerApprovedImplementationRequiresBoundInputSet(t *testing.T) { - s := newBrokerAPIServiceForTests(t, APIConfig{}) - seedSessionRuntimeFactsForOpsTest(t, s, "run-session-trigger-approved-missing", "sess-trigger-approved-missing") - _, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-approved-missing", SessionID: "sess-trigger-approved-missing", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: "approved_change_implementation"}, UserMessageContentText: "hello"}, RequestContext{}) - assertSessionExecutionContinueBlocked(t, errResp, "broker_validation_schema_invalid") -} - -func TestSessionExecutionTriggerIdempotencyIncludesWorkflowRoutingIdentity(t *testing.T) { - s := newBrokerAPIServiceForTests(t, APIConfig{}) - seedSessionRuntimeFactsForOpsTest(t, s, "run-session-trigger-routing-idem", "sess-trigger-routing-idem") - base := SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-routing-idem-1", SessionID: "sess-trigger-routing-idem", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: "draft_promote_apply"}, UserMessageContentText: "hello", IdempotencyKey: "idem-routing"} - _ = mustSessionExecutionTrigger(t, s, base) - base.RequestID = "req-session-trigger-routing-idem-2" - base.WorkflowRouting = &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: "change_draft"} - _, errResp := s.HandleSessionExecutionTrigger(context.Background(), base, RequestContext{}) - assertSessionExecutionContinueBlocked(t, errResp, "broker_idempotency_key_payload_mismatch") -} - -func TestSessionExecutionTriggerProjectsSessionRunAndSnapshotBindings(t *testing.T) { - s := newBrokerAPIServiceForTests(t, APIConfig{}) - seedSessionRuntimeFactsForOpsTest(t, s, "run-session-trigger-links", "sess-trigger-links") - seedSessionExecutionTriggerProjectionLinks(t, s) - ack := mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-links-trigger", SessionID: "sess-trigger-links", TriggerSource: "interactive_user", RequestedOperation: "start", UserMessageContentText: "go"}) - if ack.TurnID == "" { - t.Fatal("turn_id is empty") - } - getResp := mustSessionGet(t, s, "req-session-trigger-links-get", "sess-trigger-links") - exec := requireCurrentSessionExecution(t, getResp.Session) - assertSessionExecutionBindings(t, exec) -} - -func TestSessionExecutionTriggerContinueFailsClosedOnDigestDriftAndProjectsBlockedTurn(t *testing.T) { - s := newBrokerAPIServiceForTests(t, APIConfig{}) - seedSessionRuntimeFactsForOpsTest(t, s, "run-session-trigger-drift", "sess-trigger-drift") - _ = mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-drift-start", SessionID: "sess-trigger-drift", TriggerSource: "interactive_user", RequestedOperation: "start", UserMessageContentText: "start"}) - bound := requireBoundExecutionDigest(t, mustSessionGet(t, s, "req-session-trigger-drift-get-start", "sess-trigger-drift").Session) - driftDigest := digestForBrokerTest("session-trigger-drift") - if driftDigest == bound { - t.Fatal("test setup expected drift digest to differ from bound digest") - } - s.discoverProjectSubstrateFn = func() (projectsubstrate.DiscoveryResult, error) { - return projectsubstrate.DiscoveryResult{Snapshot: projectsubstrate.ValidationSnapshot{ValidatedSnapshotDigest: driftDigest, ProjectContextIdentityDigest: driftDigest}, Compatibility: projectsubstrate.CompatibilityAssessment{Posture: projectsubstrate.CompatibilityPostureSupportedCurrent, NormalOperationAllowed: true}}, nil - } - _, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-drift-continue", SessionID: "sess-trigger-drift", TriggerSource: "resume_follow_up", RequestedOperation: "continue", UserMessageContentText: "continue"}, RequestContext{}) - if errResp == nil { - t.Fatal("HandleSessionExecutionTrigger expected drift blocked error") - } - if errResp.Error.Code != "broker_session_execution_project_context_drift" { - t.Fatalf("error code = %q, want broker_session_execution_project_context_drift", errResp.Error.Code) - } - assertSessionExecutionBlockedProjection(t, mustSessionGet(t, s, "req-session-trigger-drift-get-blocked", "sess-trigger-drift").Session, "project_substrate_digest_drift") -} - -func TestSessionRuntimeFactsDoNotOverwriteBlockedSessionPosture(t *testing.T) { - s := newBrokerAPIServiceForTests(t, APIConfig{}) - seedSessionRuntimeFactsForOpsTest(t, s, "run-session-blocked-preserve", "sess-blocked-preserve") - _ = mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-blocked-preserve-start", SessionID: "sess-blocked-preserve", TriggerSource: "interactive_user", RequestedOperation: "start", UserMessageContentText: "start"}) - bound := requireBoundExecutionDigest(t, mustSessionGet(t, s, "req-session-blocked-preserve-get-start", "sess-blocked-preserve").Session) - driftDigest := digestForBrokerTest("session-blocked-preserve-drift") - if driftDigest == bound { - t.Fatal("test setup expected drift digest to differ from bound digest") - } - s.discoverProjectSubstrateFn = func() (projectsubstrate.DiscoveryResult, error) { - return projectsubstrate.DiscoveryResult{Snapshot: projectsubstrate.ValidationSnapshot{ValidatedSnapshotDigest: driftDigest, ProjectContextIdentityDigest: driftDigest}, Compatibility: projectsubstrate.CompatibilityAssessment{Posture: projectsubstrate.CompatibilityPostureSupportedCurrent, NormalOperationAllowed: true}}, nil - } - _, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-blocked-preserve-continue", SessionID: "sess-blocked-preserve", TriggerSource: "resume_follow_up", RequestedOperation: "continue", UserMessageContentText: "continue"}, RequestContext{}) - if errResp == nil { - t.Fatal("HandleSessionExecutionTrigger expected drift blocked error") - } - if err := s.RecordRuntimeFacts("run-session-blocked-preserve", launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: launcherbackend.BackendLaunchReceipt{RunID: "run-session-blocked-preserve", SessionID: "sess-blocked-preserve"}}); err != nil { - t.Fatalf("RecordRuntimeFacts returned error: %v", err) - } - blockSessionPosturePreserved(t, mustSessionGet(t, s, "req-session-blocked-preserve-get-blocked", "sess-blocked-preserve").Session) -} - -func TestSessionExecutionTriggerContinueRequiresValidatedSnapshotDigest(t *testing.T) { - s := newBrokerAPIServiceForTests(t, APIConfig{}) - seedSessionRuntimeFactsForOpsTest(t, s, "run-session-continue-digest", "sess-continue-digest") - start := mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-continue-digest-start", SessionID: "sess-continue-digest", TriggerSource: "interactive_user", RequestedOperation: "start", UserMessageContentText: "start"}) - markSessionExecutionWaiting(t, s, start.TurnID, "sess-continue-digest") - s.discoverProjectSubstrateFn = func() (projectsubstrate.DiscoveryResult, error) { - return projectsubstrate.DiscoveryResult{Snapshot: projectsubstrate.ValidationSnapshot{}, Compatibility: projectsubstrate.CompatibilityAssessment{Posture: projectsubstrate.CompatibilityPostureSupportedCurrent, NormalOperationAllowed: true}}, nil - } - _, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-continue-digest-continue", SessionID: "sess-continue-digest", TriggerSource: "resume_follow_up", RequestedOperation: "continue", UserMessageContentText: "continue"}, RequestContext{}) - assertSessionExecutionContinueBlocked(t, errResp, "project_substrate_operation_blocked") -} - -func TestSessionExecutionTriggerContinueRejectsBlockedTurnResume(t *testing.T) { - s := newBrokerAPIServiceForTests(t, APIConfig{}) - seedSessionRuntimeFactsForOpsTest(t, s, "run-session-blocked-resume", "sess-blocked-resume") - start := mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-blocked-resume-start", SessionID: "sess-blocked-resume", TriggerSource: "interactive_user", RequestedOperation: "start", UserMessageContentText: "start"}) - markSessionExecutionBlocked(t, s, start.TurnID, "sess-blocked-resume") - resp, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-blocked-resume-continue", SessionID: "sess-blocked-resume", TriggerSource: "resume_follow_up", RequestedOperation: "continue", UserMessageContentText: "continue"}, RequestContext{}) - if errResp != nil { - t.Fatalf("HandleSessionExecutionTrigger returned error: %+v", errResp) - } - if resp.ExecutionState != "running" { - t.Fatalf("execution_state = %q, want running", resp.ExecutionState) +func TestSessionExecutionTriggerMaterializesTypedDraftArtifactsForSupportedDraftOperations(t *testing.T) { + for _, tc := range []struct { + name string + operation string + requestID string + sessionID string + message string + artifactRef string + schemaID string + identityField string + identityPrefix string + }{ + {name: "change draft", operation: sessionWorkflowOperationChangeDraft, requestID: "req-session-trigger-change-draft-artifact", sessionID: "sess-trigger-change-draft-artifact", message: "Draft CHG phase 3a artifact path", artifactRef: "change_draft_artifact", schemaID: "runecode.protocol.v0.RuneContextChangeDraftArtifact", identityField: "change_id", identityPrefix: "CHG-"}, + {name: "spec draft", operation: sessionWorkflowOperationSpecDraft, requestID: "req-session-trigger-spec-draft-artifact", sessionID: "sess-trigger-spec-draft-artifact", message: "Spec phase 3a artifact path", artifactRef: "spec_draft_artifact", schemaID: "runecode.protocol.v0.RuneContextSpecDraftArtifact", identityField: "spec_id", identityPrefix: "spec-"}, + } { + t.Run(tc.name, func(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + assertSupportedDraftArtifacts(t, s, tc) + }) } } -func TestSessionExecutionTriggerAutonomousOperatorGuidedStartsWaitingForOperatorInput(t *testing.T) { - s := newBrokerAPIServiceForTests(t, APIConfig{}) - seedSessionRuntimeFactsForOpsTest(t, s, "run-session-trigger-autonomous", "sess-trigger-autonomous") - ack := mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-autonomous", SessionID: "sess-trigger-autonomous", TriggerSource: "autonomous_background", RequestedOperation: "start", AutonomyPosture: "operator_guided", UserMessageContentText: "background step"}) - if ack.ExecutionState != "waiting" { - t.Fatalf("execution_state = %q, want waiting", ack.ExecutionState) - } - getResp := mustSessionGet(t, s, "req-session-trigger-autonomous-get", "sess-trigger-autonomous") - if getResp.Session.CurrentTurnExecution == nil { - t.Fatal("current_turn_execution missing") - } - if getResp.Session.CurrentTurnExecution.WaitKind != "operator_input" { - t.Fatalf("wait_kind = %q, want operator_input", getResp.Session.CurrentTurnExecution.WaitKind) - } - if getResp.Session.CurrentTurnExecution.WaitState != "waiting_operator_input" { - t.Fatalf("wait_state = %q, want waiting_operator_input", getResp.Session.CurrentTurnExecution.WaitState) - } -} - -func TestSessionExecutionTriggerContinueRejectsWaitingApprovalUntilApprovalResolves(t *testing.T) { - s, unapproved, requestEnv, decisionEnv := setupServiceWithApprovalFixture(t) - approvalID, policyDecisionHash, storedApproval := prepareSessionExecutionApprovalFixture(t, s, requestEnv) - seedSessionRuntimeFactsForOpsTest(t, s, "run-approval", "sess-trigger-waiting-approval") - ack := mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-waiting-approval-start", SessionID: "sess-trigger-waiting-approval", TriggerSource: "autonomous_background", RequestedOperation: "start", AutonomyPosture: "balanced", UserMessageContentText: "background step"}) - if ack.ExecutionState != "running" { - t.Fatalf("execution_state = %q, want running", ack.ExecutionState) - } - recordAndAssertApprovalWait(t, s, approvalID, storedApproval.ActionRequestHash) - _, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-waiting-approval-continue-blocked", SessionID: "sess-trigger-waiting-approval", TriggerSource: "resume_follow_up", RequestedOperation: "continue", UserMessageContentText: "continue"}, RequestContext{}) - assertSessionExecutionContinueBlocked(t, errResp, "broker_session_execution_continue_waiting_approval") - resolveSessionExecutionApprovalWait(t, s, approvalID, policyDecisionHash, unapproved.Digest, requestEnv, decisionEnv) - resp, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-waiting-approval-continue-resolved", SessionID: "sess-trigger-waiting-approval", TriggerSource: "resume_follow_up", RequestedOperation: "continue", UserMessageContentText: "continue"}, RequestContext{}) - if errResp != nil { - t.Fatalf("HandleSessionExecutionTrigger returned error: %+v", errResp) - } - if resp.ExecutionState != "running" { - t.Fatalf("execution_state = %q, want running", resp.ExecutionState) - } -} - -func prepareSessionExecutionApprovalFixture(t *testing.T, s *Service, requestEnv *trustpolicy.SignedObjectEnvelope) (string, string, artifacts.ApprovalRecord) { +func assertSupportedDraftArtifacts(t *testing.T, s *Service, tc struct { + name string + operation string + requestID string + sessionID string + message string + artifactRef string + schemaID string + identityField string + identityPrefix string +}) { t.Helper() - approvalID := approvalIDForBrokerTest(t, requestEnv) - return approvalID, policyDecisionHashForStoredApproval(t, s, approvalID), mustApprovalGet(t, s, approvalID) -} - -func recordAndAssertApprovalWait(t *testing.T, s *Service, approvalID, actionHash string) { - t.Helper() - if err := s.RecordRunnerApprovalWait(artifacts.RunnerApproval{ApprovalID: approvalID, RunID: "run-approval", StageID: "artifact_flow", StepID: "step-1", RoleInstanceID: "role-1", Status: "pending", ApprovalType: "exact_action", BoundActionHash: actionHash, OccurredAt: s.currentTimestamp()}); err != nil { - t.Fatalf("RecordRunnerApprovalWait returned error: %v", err) - } - if err := s.syncSessionExecutionForRun("run-approval", s.currentTimestamp()); err != nil { - t.Fatalf("syncSessionExecutionForRun returned error: %v", err) + s.sessionExecutionRunner = launchSessionExecutionRunnerCompleteInProcessForTests + seedSessionRuntimeFactsForOpsTest(t, s, "run-"+tc.operation, tc.sessionID) + ack := mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: tc.requestID, SessionID: tc.sessionID, TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: tc.operation}, UserMessageContentText: tc.message}) + if ack.ExecutionState != "running" { + t.Fatalf("ack execution_state = %q, want running", ack.ExecutionState) } - getResp := mustSessionGet(t, s, "req-session-trigger-waiting-approval-get", "sess-trigger-waiting-approval") - exec := requireCurrentSessionExecution(t, getResp.Session) - if exec.WaitKind != "approval" { - t.Fatalf("wait_kind = %q, want approval", exec.WaitKind) + getResp := mustSessionGet(t, s, tc.requestID+"-get", tc.sessionID) + if getResp.Session.LatestTurnExecution == nil { + t.Fatal("latest_turn_execution missing after draft run") } - if exec.WaitState != "waiting_approval" { - t.Fatalf("wait_state = %q, want waiting_approval", exec.WaitState) + exec := getResp.Session.LatestTurnExecution + if exec.ExecutionState != "completed" { + t.Fatalf("latest execution_state after real path = %q, want completed", exec.ExecutionState) } - if exec.PendingApprovalID != approvalID { - t.Fatalf("pending_approval_id = %q, want %q", exec.PendingApprovalID, approvalID) + if got := exec.WorkflowRouting.WorkflowOperation; got != tc.operation { + t.Fatalf("latest workflow_operation = %q, want %q", got, tc.operation) } + assertDraftArtifactsForExecution(t, s, exec.PrimaryRunID, tc.artifactRef, tc.schemaID, tc.identityField, tc.identityPrefix, tc.message) } -func resolveSessionExecutionApprovalWait(t *testing.T, s *Service, approvalID, policyDecisionHash, unapprovedDigest string, requestEnv, decisionEnv *trustpolicy.SignedObjectEnvelope) { +func assertDraftArtifactsForExecution(t *testing.T, s *Service, runID, artifactRef, schemaID, identityField, identityPrefix, message string) { t.Helper() - resolveReq := ApprovalResolveRequest{SchemaID: "runecode.protocol.v0.ApprovalResolveRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-waiting-approval-resolve", ApprovalID: approvalID, BoundScope: ApprovalBoundScope{SchemaID: "runecode.protocol.v0.ApprovalBoundScope", SchemaVersion: "0.1.0", WorkspaceID: workspaceIDForRun("run-approval"), RunID: "run-approval", StageID: "artifact_flow", StepID: "step-1", ActionKind: policyengine.ActionKindPromotion, PolicyDecisionHash: policyDecisionHash}, UnapprovedDigest: unapprovedDigest, Approver: "human", RepoPath: "repo/file.txt", Commit: "abc123", ExtractorToolVersion: "tool-v1", FullContentVisible: true, ExplicitViewFull: false, BulkRequest: false, BulkApprovalConfirmed: false, SignedApprovalRequest: *requestEnv, SignedApprovalDecision: *decisionEnv} - if _, errResp := s.HandleApprovalResolve(context.Background(), resolveReq, RequestContext{}); errResp != nil { - t.Fatalf("HandleApprovalResolve error response: %+v", errResp) + promptStepID := "session_execution/" + strings.TrimSuffix(artifactRef, "_artifact") + "_prompt" + requireSessionExecutionLinkedArtifactByStepAndSchema(t, s, runID, promptStepID, "", message) + artifact := requireSessionExecutionLinkedArtifactByStepAndSchema(t, s, runID, "session_execution/"+artifactRef, schemaID, "") + identity := stringValueFromMap(artifact, identityField) + if !strings.HasPrefix(identity, identityPrefix) { + t.Fatalf("%s = %q, want prefix %q", identityField, identity, identityPrefix) } - resolved := mustSessionGet(t, s, "req-session-trigger-waiting-approval-get-resolved", "sess-trigger-waiting-approval") - resolvedExec := requireCurrentSessionExecution(t, resolved.Session) - if resolvedExec.WaitKind != "" { - t.Fatalf("wait_kind after resolve = %q, want empty", resolvedExec.WaitKind) + if got := digestObjectValueFromMap(artifact, "validated_project_substrate_digest"); got == "" { + t.Fatalf("typed draft artifact missing validated_project_substrate_digest: %+v", artifact) } } - -func TestSessionExecutionTriggerContinueTargetsExplicitTurn(t *testing.T) { +func TestSessionExecutionTriggerRejectsMutationBearingDraftRouting(t *testing.T) { s := newBrokerAPIServiceForTests(t, APIConfig{}) - seedSessionRuntimeFactsForOpsTest(t, s, "run-session-trigger-targeted", "sess-trigger-targeted") - first := mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-targeted-1", SessionID: "sess-trigger-targeted", TriggerSource: "autonomous_background", RequestedOperation: "start", AutonomyPosture: "operator_guided", UserMessageContentText: "first"}) - if _, err := s.UpdateSessionTurnExecution(artifacts.SessionTurnExecutionUpdateRequest{SessionID: "sess-trigger-targeted", TurnID: first.TurnID, ExecutionState: "waiting", WaitKind: "external_dependency", WaitState: "waiting_external_dependency", OccurredAt: s.currentTimestamp()}); err != nil { - t.Fatalf("UpdateSessionTurnExecution returned error: %v", err) - } - resp, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-targeted-continue", SessionID: "sess-trigger-targeted", TurnID: first.TurnID, TriggerSource: "resume_follow_up", RequestedOperation: "continue", UserMessageContentText: "continue first"}, RequestContext{}) - if errResp != nil { - t.Fatalf("HandleSessionExecutionTrigger returned error: %+v", errResp) - } - if resp.TurnID != first.TurnID { - t.Fatalf("continued turn_id = %q, want %q", resp.TurnID, first.TurnID) - } - getResp := mustSessionGet(t, s, "req-session-trigger-targeted-get", "sess-trigger-targeted") - if len(getResp.Session.PendingTurnExecutions) != 1 { - t.Fatalf("pending_turn_executions len = %d, want 1", len(getResp.Session.PendingTurnExecutions)) - } - if state := getResp.Session.PendingTurnExecutions[0].ExecutionState; state != "running" { - t.Fatalf("execution_state = %q, want running", state) - } + seedSessionRuntimeFactsForOpsTest(t, s, "run-session-trigger-draft-mutation", "sess-trigger-draft-mutation") + _, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-draft-mutation", SessionID: "sess-trigger-draft-mutation", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: "change_draft", BoundInputArtifacts: []SessionWorkflowPackBoundInputArtifact{{ArtifactRef: "change_draft_artifact", ArtifactDigest: digestForBrokerTest("x")}}}, UserMessageContentText: "hello"}, RequestContext{}) + assertSessionExecutionContinueBlocked(t, errResp, "broker_validation_schema_invalid") } -func TestSessionExecutionTriggerContinueSupportsIdempotentRetry(t *testing.T) { +func TestSessionExecutionTriggerApprovedImplementationRequiresBoundInputSet(t *testing.T) { s := newBrokerAPIServiceForTests(t, APIConfig{}) - seedSessionRuntimeFactsForOpsTest(t, s, "run-session-trigger-continue-idem", "sess-trigger-continue-idem") - start := mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-continue-idem-start", SessionID: "sess-trigger-continue-idem", TriggerSource: "autonomous_background", RequestedOperation: "start", AutonomyPosture: "operator_guided", UserMessageContentText: "wait first"}) - firstResp := mustSessionExecutionContinue(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-continue-idem-1", SessionID: "sess-trigger-continue-idem", TurnID: start.TurnID, TriggerSource: "resume_follow_up", RequestedOperation: "continue", UserMessageContentText: "continue", IdempotencyKey: "idem-continue-1"}) - assertStoredSessionExecutionTriggerIdempotencyRecord(t, s, "sess-trigger-continue-idem", "idem-continue-1", firstResp) - secondResp := mustSessionExecutionContinue(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-continue-idem-2", SessionID: "sess-trigger-continue-idem", TriggerSource: "resume_follow_up", RequestedOperation: "continue", UserMessageContentText: "continue", IdempotencyKey: "idem-continue-1"}) - assertSessionExecutionTriggerReplayResponse(t, secondResp, firstResp) -} - -func mustSessionExecutionContinue(t *testing.T, s *Service, req SessionExecutionTriggerRequest) SessionExecutionTriggerResponse { - t.Helper() - resp, errResp := s.HandleSessionExecutionTrigger(context.Background(), req, RequestContext{}) - if errResp != nil { - t.Fatalf("HandleSessionExecutionTrigger returned error: %+v", errResp) - } - return resp + seedSessionRuntimeFactsForOpsTest(t, s, "run-session-trigger-approved-missing", "sess-trigger-approved-missing") + _, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-trigger-approved-missing", SessionID: "sess-trigger-approved-missing", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: "approved_change_implementation"}, UserMessageContentText: "hello"}, RequestContext{}) + assertSessionExecutionContinueBlocked(t, errResp, "broker_validation_schema_invalid") } -func assertStoredSessionExecutionTriggerIdempotencyRecord(t *testing.T, s *Service, sessionID, key string, resp SessionExecutionTriggerResponse) { - t.Helper() - if resp.TriggerID == "" { - t.Fatal("trigger_id is empty") - } - state, ok := s.SessionState(sessionID) - if !ok { - t.Fatal("SessionState missing") - } - record, ok := state.ExecutionTriggerIdempotencyByKey[key] - if !ok { - t.Fatal("continue idempotency record missing") - } - if record.TriggerID != resp.TriggerID { - t.Fatalf("stored trigger_id = %q, want %q", record.TriggerID, resp.TriggerID) - } - if record.TurnID != resp.TurnID { - t.Fatalf("stored turn_id = %q, want %q", record.TurnID, resp.TurnID) - } - if record.Seq != resp.Seq { - t.Fatalf("stored seq = %d, want %d", record.Seq, resp.Seq) +func TestSessionExecutionTriggerApprovedImplementationRejectsUnapprovedMutationDigest(t *testing.T) { + repoRoot := t.TempDir() + writeProjectSubstrateAnchors(t, repoRoot, "0.1.0-alpha.14", "verified", "runecontext") + s := newBrokerAPIServiceForTests(t, APIConfig{RepositoryRoot: repoRoot}) + seedSessionRuntimeFactsForOpsTest(t, s, "run-approved-impl-invalid", "sess-approved-impl-invalid") + + proposalText := "# CHG-approved-impl-invalid\n" + proposalDigest := putApprovedImplementationMutationArtifactForTest(t, s, map[string]any{ + "target_path": "runecontext/changes/CHG-approved-impl-invalid/proposal.md", + "content": proposalText, + "content_digest": digestObject(artifacts.DigestBytes([]byte(proposalText))), + "write_mode": "create", + }) + fixture := approvedImplementationInputSetFixture(t, s, []string{artifacts.DigestBytes([]byte("approved-only"))}, nil, nil) + fixture["workspace_mutation_digests"] = digestObjects([]string{proposalDigest}) + inputSetDigest := putApprovedImplementationInputSetForTest(t, s, fixture) + + _, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-approved-impl-invalid", SessionID: "sess-approved-impl-invalid", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationApprovedImplementation, BoundInputArtifacts: []SessionWorkflowPackBoundInputArtifact{{ArtifactRef: "implementation_input_set", ArtifactDigest: inputSetDigest}}}, UserMessageContentText: "apply approved implementation"}, RequestContext{}) + assertSessionExecutionContinueBlocked(t, errResp, "broker_storage_write_failed") + if !strings.Contains(errResp.Error.Message, "not included in approved_input_digests") { + t.Fatalf("error message = %q, want unapproved mutation digest detail", errResp.Error.Message) } } -func assertSessionExecutionTriggerReplayResponse(t *testing.T, got, want SessionExecutionTriggerResponse) { - t.Helper() - if got.Seq != want.Seq { - t.Fatalf("replay seq = %d, want %d", got.Seq, want.Seq) - } - if got.TurnID != want.TurnID { - t.Fatalf("replay turn_id = %q, want %q", got.TurnID, want.TurnID) - } - if got.TriggerID != want.TriggerID { - t.Fatalf("replay trigger_id = %q, want %q", got.TriggerID, want.TriggerID) +func TestSessionExecutionTriggerApprovedImplementationRejectsEmbeddedInputSetDigestDrift(t *testing.T) { + repoRoot := t.TempDir() + writeProjectSubstrateAnchors(t, repoRoot, "0.1.0-alpha.14", "verified", "runecontext") + s := newBrokerAPIServiceForTests(t, APIConfig{RepositoryRoot: repoRoot}) + seedSessionRuntimeFactsForOpsTest(t, s, "run-approved-impl-digest-drift", "sess-approved-impl-digest-drift") + + proposalText := "# CHG-approved-impl-digest-drift\n" + proposalDigest := putApprovedImplementationMutationArtifactForTest(t, s, map[string]any{ + "target_path": "runecontext/changes/CHG-approved-impl-digest-drift/proposal.md", + "content": proposalText, + "content_digest": digestObject(artifacts.DigestBytes([]byte(proposalText))), + "write_mode": "create", + }) + payload := approvedImplementationInputSetFixture(t, s, []string{proposalDigest}, []string{proposalDigest}, nil) + payload["input_set_digest"] = digestObject("sha256:" + strings.Repeat("f", 64)) + inputSetArtifactDigest := putApprovedImplementationInputSetArtifactForTest(t, s, payload) + + _, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-approved-impl-digest-drift", SessionID: "sess-approved-impl-digest-drift", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationApprovedImplementation, BoundInputArtifacts: []SessionWorkflowPackBoundInputArtifact{{ArtifactRef: "implementation_input_set", ArtifactDigest: inputSetArtifactDigest}}}, UserMessageContentText: "apply approved implementation"}, RequestContext{}) + assertSessionExecutionContinueBlocked(t, errResp, "broker_validation_schema_invalid") + if !strings.Contains(errResp.Error.Message, "input_set_digest drift detected") { + t.Fatalf("error message = %q, want input_set_digest drift detail", errResp.Error.Message) } } diff --git a/internal/brokerapi/local_api_session_execution_trigger_plan_authority.go b/internal/brokerapi/local_api_session_execution_trigger_plan_authority.go new file mode 100644 index 00000000..1a2f44d3 --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_trigger_plan_authority.go @@ -0,0 +1,238 @@ +package brokerapi + +import ( + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/runplan" +) + +type sessionExecutionPlanAuthority struct { + runID string + planID string + runPlanDigest string + planCheckpointCode string + planOrderIndex int + gateID string + gateKind string + gateVersion string + stageID string + stepID string + roleInstanceID string + expectedInputDigest string + workflowDefinitionRef string + processDefinitionRef string + workflowDefinitionHash string + processDefinitionHash string + projectContextIdentityDigest string + workflowOperation string + draftArtifactSchemaID string +} + +func (s *Service) ensureSessionExecutionRunPlanAuthority(result artifacts.SessionExecutionTriggerAppendResult) (sessionExecutionPlanAuthority, error) { + authorityInputs, err := sessionExecutionAuthorityInputs(result) + if err != nil { + return sessionExecutionPlanAuthority{}, err + } + workflowRef, processRef, err := s.persistSessionExecutionAuthorityAssets(authorityInputs.runID, authorityInputs.entry.WorkflowID) + if err != nil { + return sessionExecutionPlanAuthority{}, err + } + projectContextIdentityDigest, err := sessionExecutionProjectContextIdentityDigest(s, result.TurnExecution) + if err != nil { + return sessionExecutionPlanAuthority{}, err + } + compiled, err := s.compileSessionExecutionRunPlan(authorityInputs.runID, sessionExecutionPlanID(authorityInputs.runID, result.TurnExecution.ExecutionIndex), workflowRef.Digest, processRef.Digest, projectContextIdentityDigest, result) + if err != nil { + return sessionExecutionPlanAuthority{}, err + } + selectedEntry, err := s.sessionExecutionSelectedPlanEntry(authorityInputs.runID) + if err != nil { + return sessionExecutionPlanAuthority{}, err + } + return newSessionExecutionPlanAuthority(authorityInputs, compiled, selectedEntry, workflowRef, processRef, projectContextIdentityDigest), nil +} + +type sessionExecutionAuthorityInputSet struct { + runID string + workflowOperation string + entry runplan.BuiltInWorkflowCatalogEntry +} + +func sessionExecutionAuthorityInputs(result artifacts.SessionExecutionTriggerAppendResult) (sessionExecutionAuthorityInputSet, error) { + runID := strings.TrimSpace(result.TurnExecution.PrimaryRunID) + if runID == "" { + return sessionExecutionAuthorityInputSet{}, fmt.Errorf("session execution run binding missing primary run id") + } + workflowOperation := strings.TrimSpace(result.TurnExecution.WorkflowRouting.WorkflowOperation) + entry, err := builtInCatalogEntryForWorkflowOperation(workflowOperation) + if err != nil { + return sessionExecutionAuthorityInputSet{}, err + } + return sessionExecutionAuthorityInputSet{runID: runID, workflowOperation: workflowOperation, entry: entry}, nil +} + +func (s *Service) persistSessionExecutionAuthorityAssets(runID, workflowID string) (artifacts.ArtifactReference, artifacts.ArtifactReference, error) { + workflowPayload, processPayload, err := builtInWorkflowAssetPayloads(workflowID) + if err != nil { + return artifacts.ArtifactReference{}, artifacts.ArtifactReference{}, err + } + return s.persistSessionExecutionWorkflowAssets(runID, workflowPayload, processPayload) +} + +func sessionExecutionProjectContextIdentityDigest(s *Service, turnExecution artifacts.SessionTurnExecutionDurableState) (string, error) { + projectContextIdentityDigest := strings.TrimSpace(s.projectSubstrate.Snapshot.ProjectContextIdentityDigest) + if projectContextIdentityDigest == "" { + projectContextIdentityDigest = strings.TrimSpace(turnExecution.BoundValidatedProjectSubstrateDigest) + } + if projectContextIdentityDigest == "" { + return "", fmt.Errorf("validated project context identity digest is required for session execution run plan authority") + } + return projectContextIdentityDigest, nil +} + +func (s *Service) compileSessionExecutionRunPlan(runID, planID, workflowRef, processRef, projectContextIdentityDigest string, result artifacts.SessionExecutionTriggerAppendResult) (CompileAndPersistRunPlanResult, error) { + approvedInputSetDigest, err := approvedInputSetSemanticDigestForSessionExecution(s, result) + if err != nil { + return CompileAndPersistRunPlanResult{}, err + } + compiled, err := s.CompileAndPersistRunPlan(CompileAndPersistRunPlanRequest{ + RunID: runID, + PlanID: planID, + WorkflowDefinitionRef: workflowRef, + ProcessDefinitionRef: processRef, + PolicyContextHash: sessionExecutionPolicyContextHash(result), + ProjectContextIdentityDigest: projectContextIdentityDigest, + ApprovedInputSetDigest: approvedInputSetDigest, + }) + if err != nil { + return CompileAndPersistRunPlanResult{}, err + } + if strings.TrimSpace(compiled.PlanID) == "" { + return CompileAndPersistRunPlanResult{}, fmt.Errorf("trusted run plan compilation returned empty plan id") + } + return compiled, nil +} + +func (s *Service) sessionExecutionSelectedPlanEntry(runID string) (artifacts.RunPlanGateEntryRecord, error) { + authorityRecord, ok, err := s.ActiveRunPlanAuthority(runID) + if err != nil { + return artifacts.RunPlanGateEntryRecord{}, err + } + if !ok { + return artifacts.RunPlanGateEntryRecord{}, fmt.Errorf("trusted run plan authority missing after compile for run %q", runID) + } + return selectSessionExecutionPlanEntry(authorityRecord.Entries) +} + +func firstExpectedInputDigest(entry artifacts.RunPlanGateEntryRecord) string { + for _, digest := range entry.ExpectedInputDigests { + trimmed := strings.TrimSpace(digest) + if trimmed != "" { + return trimmed + } + } + return "" +} + +func selectSessionExecutionPlanEntry(entries []artifacts.RunPlanGateEntryRecord) (artifacts.RunPlanGateEntryRecord, error) { + if len(entries) == 0 { + return artifacts.RunPlanGateEntryRecord{}, fmt.Errorf("trusted run plan authority has no gate entries") + } + selected := entries[0] + for _, entry := range entries[1:] { + if entry.PlanOrderIndex > selected.PlanOrderIndex { + selected = entry + } + } + if strings.TrimSpace(selected.PlanCheckpointCode) == "" { + return artifacts.RunPlanGateEntryRecord{}, fmt.Errorf("trusted run plan authority selected entry missing plan_checkpoint_code") + } + if strings.TrimSpace(selected.GateID) == "" || strings.TrimSpace(selected.GateKind) == "" || strings.TrimSpace(selected.GateVersion) == "" { + return artifacts.RunPlanGateEntryRecord{}, fmt.Errorf("trusted run plan authority selected entry missing gate identity") + } + return selected, nil +} + +func (s *Service) persistSessionExecutionWorkflowAssets(runID string, workflowPayload, processPayload []byte) (artifacts.ArtifactReference, artifacts.ArtifactReference, error) { + workflowRef, err := s.Put(artifacts.PutRequest{ + Payload: workflowPayload, + ContentType: "application/json", + DataClass: artifacts.DataClassSpecText, + ProvenanceReceiptHash: artifacts.DigestBytes(workflowPayload), + CreatedByRole: "brokerapi", + TrustedSource: true, + RunID: runID, + StepID: "session_execution/workflow_definition", + }) + if err != nil { + return artifacts.ArtifactReference{}, artifacts.ArtifactReference{}, fmt.Errorf("persist built-in workflow definition: %w", err) + } + processRef, err := s.Put(artifacts.PutRequest{ + Payload: processPayload, + ContentType: "application/json", + DataClass: artifacts.DataClassSpecText, + ProvenanceReceiptHash: artifacts.DigestBytes(processPayload), + CreatedByRole: "brokerapi", + TrustedSource: true, + RunID: runID, + StepID: "session_execution/process_definition", + }) + if err != nil { + return artifacts.ArtifactReference{}, artifacts.ArtifactReference{}, fmt.Errorf("persist built-in process definition: %w", err) + } + return workflowRef, processRef, nil +} + +func sessionExecutionPlanID(runID string, executionIndex int) string { + return sessionExecutionDerivedPlanID(runID, executionIndex) +} + +func sessionExecutionPolicyContextHash(result artifacts.SessionExecutionTriggerAppendResult) string { + payload := strings.TrimSpace(result.TurnExecution.WorkflowRouting.WorkflowFamily) + "\n" + strings.TrimSpace(result.TurnExecution.WorkflowRouting.WorkflowOperation) + "\n" + strings.TrimSpace(result.TurnExecution.BoundValidatedProjectSubstrateDigest) + if payload == "\n\n" { + payload = strings.TrimSpace(result.Trigger.TriggerID) + } + return shaDigestIdentity(payload) +} + +func approvedInputSetSemanticDigestForSessionExecution(s *Service, result artifacts.SessionExecutionTriggerAppendResult) (string, error) { + if strings.TrimSpace(result.TurnExecution.WorkflowRouting.WorkflowOperation) != sessionWorkflowOperationApprovedImplementation { + return "", nil + } + for _, binding := range result.TurnExecution.WorkflowRouting.BoundInputArtifacts { + if strings.TrimSpace(binding.ArtifactRef) == "implementation_input_set" { + inputSet, errResp := s.decodeApprovedImplementationInputSet("compile_session_execution_run_plan", strings.TrimSpace(binding.ArtifactDigest)) + if errResp != nil { + return "", fmt.Errorf("%s", strings.TrimSpace(errResp.Error.Message)) + } + return strings.TrimSpace(inputSet.inputSetDigest), nil + } + } + return "", nil +} + +func newSessionExecutionPlanAuthority(inputs sessionExecutionAuthorityInputSet, compiled CompileAndPersistRunPlanResult, selectedEntry artifacts.RunPlanGateEntryRecord, workflowRef, processRef artifacts.ArtifactReference, projectContextIdentityDigest string) sessionExecutionPlanAuthority { + return sessionExecutionPlanAuthority{ + runID: inputs.runID, + planID: strings.TrimSpace(compiled.PlanID), + runPlanDigest: strings.TrimSpace(compiled.RunPlanDigest), + planCheckpointCode: strings.TrimSpace(selectedEntry.PlanCheckpointCode), + planOrderIndex: selectedEntry.PlanOrderIndex, + gateID: strings.TrimSpace(selectedEntry.GateID), + gateKind: strings.TrimSpace(selectedEntry.GateKind), + gateVersion: strings.TrimSpace(selectedEntry.GateVersion), + stageID: strings.TrimSpace(selectedEntry.StageID), + stepID: strings.TrimSpace(selectedEntry.StepID), + roleInstanceID: strings.TrimSpace(selectedEntry.RoleInstanceID), + expectedInputDigest: firstExpectedInputDigest(selectedEntry), + workflowDefinitionRef: workflowRef.Digest, + processDefinitionRef: processRef.Digest, + workflowDefinitionHash: strings.TrimSpace(inputs.entry.WorkflowDefinitionHash), + processDefinitionHash: strings.TrimSpace(inputs.entry.ProcessDefinitionHash), + projectContextIdentityDigest: projectContextIdentityDigest, + workflowOperation: inputs.workflowOperation, + draftArtifactSchemaID: strings.TrimSpace(inputs.entry.DraftArtifactSchemaID), + } +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_plan_authority_assets.go b/internal/brokerapi/local_api_session_execution_trigger_plan_authority_assets.go new file mode 100644 index 00000000..176c90b2 --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_trigger_plan_authority_assets.go @@ -0,0 +1,81 @@ +package brokerapi + +import ( + "fmt" + "io/fs" + "strings" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/runplan" + "github.com/runecode-ai/runecode/internal/workflowpackassets" +) + +func builtInCatalogEntryForWorkflowOperation(operation string) (runplan.BuiltInWorkflowCatalogEntry, error) { + workflowID, err := builtInWorkflowIDForOperation(operation) + if err != nil { + return runplan.BuiltInWorkflowCatalogEntry{}, err + } + for _, entry := range runplan.BuiltInWorkflowCatalogV0() { + if strings.TrimSpace(entry.WorkflowID) == workflowID { + return entry, nil + } + } + return runplan.BuiltInWorkflowCatalogEntry{}, fmt.Errorf("built-in workflow catalog entry missing for workflow operation %q", strings.TrimSpace(operation)) +} + +func builtInWorkflowIDForOperation(operation string) (string, error) { + switch strings.TrimSpace(operation) { + case sessionWorkflowOperationChangeDraft: + return "builtin_rc_change_draft_v0", nil + case sessionWorkflowOperationSpecDraft: + return "builtin_rc_spec_draft_v0", nil + case sessionWorkflowOperationDraftPromoteApply: + return "builtin_rc_draft_promote_v0", nil + case sessionWorkflowOperationApprovedImplementation: + return "builtin_rc_approved_implementation_v0", nil + default: + return "", fmt.Errorf("unsupported workflow operation %q", strings.TrimSpace(operation)) + } +} + +func builtInWorkflowAssetPayloads(workflowID string) ([]byte, []byte, error) { + workflowPath, processPath, err := builtInAssetPathsForWorkflow(workflowID) + if err != nil { + return nil, nil, err + } + assetFS := workflowpackassets.BuiltInFS() + processPayload, err := fs.ReadFile(assetFS, processPath) + if err != nil { + return nil, nil, fmt.Errorf("read built-in process asset %q: %w", processPath, err) + } + processCanonical, err := artifacts.CanonicalizeJSONBytes(processPayload) + if err != nil { + return nil, nil, fmt.Errorf("canonicalize built-in process asset %q: %w", processPath, err) + } + processDigest := artifacts.DigestBytes(processCanonical) + workflowTemplate, err := fs.ReadFile(assetFS, workflowPath) + if err != nil { + return nil, nil, fmt.Errorf("read built-in workflow asset %q: %w", workflowPath, err) + } + workflowResolved := strings.ReplaceAll(string(workflowTemplate), "{{PROCESS_HASH}}", processDigest) + workflowCanonical, err := artifacts.CanonicalizeJSONBytes([]byte(workflowResolved)) + if err != nil { + return nil, nil, fmt.Errorf("canonicalize built-in workflow asset %q: %w", workflowPath, err) + } + return workflowCanonical, processCanonical, nil +} + +func builtInAssetPathsForWorkflow(workflowID string) (string, string, error) { + switch strings.TrimSpace(workflowID) { + case "builtin_rc_change_draft_v0": + return "builtins/v0/change_draft.workflow.json", "builtins/v0/change_draft.process.json", nil + case "builtin_rc_spec_draft_v0": + return "builtins/v0/spec_draft.workflow.json", "builtins/v0/spec_draft.process.json", nil + case "builtin_rc_draft_promote_v0": + return "builtins/v0/draft_promote.workflow.json", "builtins/v0/draft_promote.process.json", nil + case "builtin_rc_approved_implementation_v0": + return "builtins/v0/approved_implementation.workflow.json", "builtins/v0/approved_implementation.process.json", nil + default: + return "", "", fmt.Errorf("built-in workflow asset paths missing for workflow id %q", strings.TrimSpace(workflowID)) + } +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_plan_authority_test.go b/internal/brokerapi/local_api_session_execution_trigger_plan_authority_test.go new file mode 100644 index 00000000..aa610dad --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_trigger_plan_authority_test.go @@ -0,0 +1,78 @@ +package brokerapi + +import ( + "context" + "strings" + "testing" + + "github.com/runecode-ai/runecode/internal/artifacts" +) + +func TestEnsureSessionExecutionRunPlanAuthorityCompilesBuiltInPlan(t *testing.T) { + s := newBrokerAPIServiceForTests(t, APIConfig{}) + s.sessionExecutionRunner = launchSessionExecutionRunnerCompleteInProcessForTests + runID := "run-session-plan-authority" + if err := s.SetRunStatus(runID, "starting"); err != nil { + t.Fatalf("SetRunStatus returned error: %v", err) + } + result := sessionExecutionPlanAuthorityAppendResult(s, runID) + authority, err := s.ensureSessionExecutionRunPlanAuthority(result) + if err != nil { + t.Fatalf("ensureSessionExecutionRunPlanAuthority returned error: %v", err) + } + assertSessionExecutionPlanAuthorityFields(t, authority) + stored, ok, err := s.ActiveRunPlanAuthority(runID) + if err != nil { + t.Fatalf("ActiveRunPlanAuthority returned error: %v", err) + } + if !ok { + t.Fatal("active run plan authority missing") + } + if stored.PlanID != authority.planID { + t.Fatalf("stored plan_id = %q, want %q", stored.PlanID, authority.planID) + } + if stored.RunPlanDigest != authority.runPlanDigest { + t.Fatalf("stored run_plan_digest = %q, want %q", stored.RunPlanDigest, authority.runPlanDigest) + } + if err := s.bridgeSessionExecutionTriggerToRun(context.Background(), "req-session-plan-authority", result, authority); err != nil { + t.Fatalf("bridgeSessionExecutionTriggerToRun returned error: %v", err) + } + runnerAdvisory, ok := s.RunnerAdvisory(runID) + if !ok { + t.Fatal("runner advisory missing after bridged checkpoint") + } + if runnerAdvisory.Lifecycle == nil || strings.TrimSpace(runnerAdvisory.Lifecycle.LifecycleState) != "completed" { + t.Fatalf("runner advisory lifecycle = %+v, want completed", runnerAdvisory.Lifecycle) + } +} + +func sessionExecutionPlanAuthorityAppendResult(s *Service, runID string) artifacts.SessionExecutionTriggerAppendResult { + return artifacts.SessionExecutionTriggerAppendResult{ + Trigger: artifacts.SessionExecutionTriggerDurableState{SessionID: "sess-session-plan-authority", TriggerID: "trigger-session-plan-authority"}, + TurnExecution: artifacts.SessionTurnExecutionDurableState{ + ExecutionIndex: 1, + PrimaryRunID: runID, + BoundValidatedProjectSubstrateDigest: s.projectSubstrate.Snapshot.ValidatedSnapshotDigest, + WorkflowRouting: artifacts.SessionWorkflowPackRoutingDurableState{ + WorkflowFamily: "runecontext", + WorkflowOperation: sessionWorkflowOperationChangeDraft, + }, + }, + } +} + +func assertSessionExecutionPlanAuthorityFields(t *testing.T, authority sessionExecutionPlanAuthority) { + t.Helper() + if authority.planID == "" { + t.Fatal("plan_id is empty") + } + if authority.planCheckpointCode == "" { + t.Fatal("plan_checkpoint_code is empty") + } + if authority.gateID == "" { + t.Fatal("gate_id is empty") + } + if authority.draftArtifactSchemaID != "runecode.protocol.v0.RuneContextChangeDraftArtifact" { + t.Fatalf("draft_artifact_schema_id = %q", authority.draftArtifactSchemaID) + } +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_promote_apply_test.go b/internal/brokerapi/local_api_session_execution_trigger_promote_apply_test.go new file mode 100644 index 00000000..c2571f6a --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_trigger_promote_apply_test.go @@ -0,0 +1,323 @@ +package brokerapi + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/policyengine" +) + +func TestSessionExecutionTriggerDraftPromoteApplyWritesCanonicalChangeDraft(t *testing.T) { + repoRoot, s := newSessionExecutionTriggerWorkflowService(t, "run-change-promote", "sess-change-promote") + draft := runChangeDraftForPromoteApply(t, s, "sess-change-promote", "req-change-promote-draft", "Draft change promote apply path") + + mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-change-promote-apply", SessionID: "sess-change-promote", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationDraftPromoteApply, BoundInputArtifacts: []SessionWorkflowPackBoundInputArtifact{{ArtifactRef: "change_draft_artifact", ArtifactDigest: draft.digest}}}, UserMessageContentText: "apply reviewed change draft"}) + + requireFileContents(t, repoRoot, filepath.ToSlash(filepath.Join("runecontext/changes", draft.changeOrSpecID, "proposal.md")), draft.draftText) + assertDraftPromoteApplyExecution(t, s, "req-change-promote-apply-get", "sess-change-promote") + assertDraftPromoteApplyAuditEvent(t, s, draft.digest) +} + +func TestSessionExecutionTriggerDraftPromoteApplyWritesCanonicalSpecDraft(t *testing.T) { + repoRoot, s := newSessionExecutionTriggerWorkflowService(t, "run-spec-promote", "sess-spec-promote") + draft := runSpecDraftForPromoteApply(t, s, "sess-spec-promote", "req-spec-promote-draft", "Spec promote apply path") + + mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-spec-promote-apply", SessionID: "sess-spec-promote", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationDraftPromoteApply, BoundInputArtifacts: []SessionWorkflowPackBoundInputArtifact{{ArtifactRef: "spec_draft_artifact", ArtifactDigest: draft.digest}}}, UserMessageContentText: "apply reviewed spec draft"}) + + requireFileContents(t, repoRoot, filepath.ToSlash(filepath.Join("runecontext/specs", draft.changeOrSpecID+".md")), draft.draftText) + assertDraftPromoteApplyExecution(t, s, "req-spec-promote-apply-get", "sess-spec-promote") + assertDraftPromoteApplyAuditEvent(t, s, draft.digest) +} + +func TestSessionExecutionTriggerApprovedImplementationAppliesWorkspaceAndLifecycleMetadataMutations(t *testing.T) { + repoRoot, s := newSessionExecutionTriggerWorkflowService(t, "run-approved-impl", "sess-approved-impl") + inputSetArtifactDigest, inputSetDigest, proposalText, tasksText := seedApprovedImplementationWorkspaceMutationFixture(t, s) + ack := mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-approved-impl", SessionID: "sess-approved-impl", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationApprovedImplementation, BoundInputArtifacts: []SessionWorkflowPackBoundInputArtifact{{ArtifactRef: "implementation_input_set", ArtifactDigest: inputSetArtifactDigest}}}, UserMessageContentText: "apply approved implementation"}) + + if ack.ExecutionState != "running" { + t.Fatalf("ack execution_state = %q, want running", ack.ExecutionState) + } + requireFileContents(t, repoRoot, filepath.ToSlash(filepath.Join("runecontext/changes", "CHG-approved-impl", "proposal.md")), proposalText) + requireFileContents(t, repoRoot, filepath.ToSlash(filepath.Join("runecontext/changes", "CHG-approved-impl", "tasks.md")), tasksText) + assertApprovedImplementationExecution(t, s, "req-approved-impl-get", "sess-approved-impl") + assertApprovedImplementationAuditEvent(t, s, inputSetArtifactDigest, inputSetDigest) +} + +func TestSessionExecutionTriggerSessionDetailProjectsExecutionOwnedLinksForCompletedWorkflowLoop(t *testing.T) { + _, s := newSessionExecutionTriggerWorkflowService(t, "run-trigger-loop-links", "sess-trigger-loop-links") + draft := runChangeDraftForPromoteApply(t, s, "sess-trigger-loop-links", "req-trigger-loop-links-draft", "Loop inspectability draft") + mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-trigger-loop-links-apply", SessionID: "sess-trigger-loop-links", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationDraftPromoteApply, BoundInputArtifacts: []SessionWorkflowPackBoundInputArtifact{{ArtifactRef: "change_draft_artifact", ArtifactDigest: draft.digest}}}, UserMessageContentText: "apply inspected draft"}) + assertCompletedWorkflowLoopProjectsOwnedLinks(t, s, draft.digest, draft.primaryRunID) +} + +func TestSessionExecutionTriggerDraftPromoteApplyRollsBackOnAuditFailure(t *testing.T) { + repoRoot, s := newSessionExecutionTriggerWorkflowService(t, "run-change-promote-rollback", "sess-change-promote-rollback") + draft := runChangeDraftForPromoteApply(t, s, "sess-change-promote-rollback", "req-change-promote-rollback-draft", "Draft rollback path") + target := filepath.Join(repoRoot, filepath.FromSlash(filepath.Join("runecontext/changes", draft.changeOrSpecID, "proposal.md"))) + beforeDecisionCount := len(s.PolicyDecisionRefsForRun("run-change-promote-rollback")) + beforeApprovals := len(s.ApprovalList()) + brokerOwnedMutationPostWriteHookForTest = func(path string) error { + if path == target { + return os.WriteFile(path, []byte("tampered-after-write"), 0o644) + } + return nil + } + defer func() { brokerOwnedMutationPostWriteHookForTest = nil }() + _, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-change-promote-rollback-apply", SessionID: "sess-change-promote-rollback", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationDraftPromoteApply, BoundInputArtifacts: []SessionWorkflowPackBoundInputArtifact{{ArtifactRef: "change_draft_artifact", ArtifactDigest: draft.digest}}}, UserMessageContentText: "apply reviewed change draft"}, RequestContext{}) + assertSessionExecutionContinueBlocked(t, errResp, "broker_storage_write_failed") + if !strings.Contains(errResp.Error.Message, "post-write digest drift") { + t.Fatalf("error message = %q, want post-write digest drift", errResp.Error.Message) + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("draft promote target exists after rollback, stat err = %v", err) + } + if got := len(s.PolicyDecisionRefsForRun("run-change-promote-rollback")); got != beforeDecisionCount { + t.Fatalf("policy decision count = %d, want %d", got, beforeDecisionCount) + } + if got := len(s.ApprovalList()); got != beforeApprovals { + t.Fatalf("approval count = %d, want %d", got, beforeApprovals) + } + if auditEventContainsValue(mustReadAuditEvents(t, s), "runecontext_draft_promote_apply", "draft_artifact_digest", draft.digest) { + t.Fatalf("unexpected draft promote/apply audit event for %q", draft.digest) + } +} + +func TestSessionExecutionTriggerApprovedImplementationSupportsContentArtifactDigestAndRollback(t *testing.T) { + repoRoot, s := newSessionExecutionTriggerWorkflowService(t, "run-approved-impl-artifact", "sess-approved-impl-artifact") + inputSetArtifactDigest, tasksPath := seedApprovedImplementationContentArtifactRollbackFixture(t, repoRoot, s) + beforeDecisionCount := len(s.PolicyDecisionRefsForRun("run-approved-impl-artifact")) + beforeApprovals := len(s.ApprovalList()) + brokerOwnedMutationPostWriteHookForTest = func(path string) error { + if path == tasksPath { + return os.WriteFile(path, []byte("tampered-after-write"), 0o644) + } + return nil + } + defer func() { brokerOwnedMutationPostWriteHookForTest = nil }() + _, errResp := s.HandleSessionExecutionTrigger(context.Background(), SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-approved-impl-artifact", SessionID: "sess-approved-impl-artifact", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationApprovedImplementation, BoundInputArtifacts: []SessionWorkflowPackBoundInputArtifact{{ArtifactRef: "implementation_input_set", ArtifactDigest: inputSetArtifactDigest}}}, UserMessageContentText: "apply approved implementation"}, RequestContext{}) + assertSessionExecutionContinueBlocked(t, errResp, "broker_storage_write_failed") + if !strings.Contains(errResp.Error.Message, "post-write digest drift") { + t.Fatalf("error message = %q, want post-write digest drift", errResp.Error.Message) + } + requireFileContents(t, repoRoot, filepath.ToSlash(filepath.Join("runecontext/changes", "CHG-approved-impl-artifact", "proposal.md")), "old proposal") + if _, err := os.Stat(tasksPath); !os.IsNotExist(err) { + t.Fatalf("tasks path exists after rollback, stat err = %v", err) + } + if got := len(s.PolicyDecisionRefsForRun("run-approved-impl-artifact")); got != beforeDecisionCount { + t.Fatalf("policy decision count = %d, want %d", got, beforeDecisionCount) + } + if got := len(s.ApprovalList()); got != beforeApprovals { + t.Fatalf("approval count = %d, want %d", got, beforeApprovals) + } + if auditEventContainsValue(mustReadAuditEvents(t, s), "runecontext_approved_implementation_applied", "input_set_artifact_digest", inputSetArtifactDigest) { + t.Fatalf("unexpected approved implementation audit event for %q", inputSetArtifactDigest) + } +} + +func seedApprovedImplementationContentArtifactRollbackFixture(t *testing.T, repoRoot string, s *Service) (string, string) { + t.Helper() + changeID := "CHG-approved-impl-artifact" + proposalPath := filepath.Join(repoRoot, filepath.FromSlash(filepath.Join("runecontext/changes", changeID, "proposal.md"))) + if err := os.MkdirAll(filepath.Dir(proposalPath), 0o755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + if err := os.WriteFile(proposalPath, []byte("old proposal"), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + proposalText := "# artifact proposal\n" + tasksText := "# artifact tasks\n" + proposalContentRef, err := s.Put(artifacts.PutRequest{Payload: []byte(proposalText), ContentType: "text/plain", DataClass: artifacts.DataClassSpecText, ProvenanceReceiptHash: artifacts.DigestBytes([]byte(proposalText)), CreatedByRole: "test", TrustedSource: true}) + if err != nil { + t.Fatalf("Put proposal content returned error: %v", err) + } + proposalDigest := putApprovedImplementationMutationArtifactForTest(t, s, map[string]any{ + "target_path": filepath.ToSlash(filepath.Join("runecontext/changes", changeID, "proposal.md")), + "content_artifact_digest": proposalContentRef.Digest, + "content_digest": digestObject(artifacts.DigestBytes([]byte(proposalText))), + "write_mode": "update", + }) + tasksDigest := putApprovedImplementationMutationArtifactForTest(t, s, map[string]any{ + "target_path": filepath.ToSlash(filepath.Join("runecontext/changes", changeID, "tasks.md")), + "content": tasksText, + "content_digest": digestObject(artifacts.DigestBytes([]byte(tasksText))), + "write_mode": "create", + }) + payload := approvedImplementationInputSetFixture(t, s, []string{proposalDigest, tasksDigest}, []string{proposalDigest, tasksDigest}, nil) + if _, ok := approvedImplementationInputSetDigest(payload); !ok { + t.Fatal("approvedImplementationInputSetDigest returned invalid fixture digest") + } + return putApprovedImplementationInputSetForTest(t, s, payload), filepath.Join(repoRoot, filepath.FromSlash(filepath.Join("runecontext/changes", changeID, "tasks.md"))) +} + +type draftPromoteApplyFixture struct { + digest string + changeOrSpecID string + draftText string + primaryRunID string +} + +func newSessionExecutionTriggerWorkflowService(t *testing.T, runID, sessionID string) (string, *Service) { + t.Helper() + repoRoot := t.TempDir() + writeProjectSubstrateAnchors(t, repoRoot, "0.1.0-alpha.14", "verified", "runecontext") + s := newBrokerAPIServiceForTests(t, APIConfig{RepositoryRoot: repoRoot}) + s.sessionExecutionRunner = launchSessionExecutionRunnerCompleteInProcessForTests + seedSessionRuntimeFactsForOpsTest(t, s, runID, sessionID) + return repoRoot, s +} + +func runChangeDraftForPromoteApply(t *testing.T, s *Service, sessionID, requestID, message string) draftPromoteApplyFixture { + t.Helper() + ack := mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: requestID, SessionID: sessionID, TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationChangeDraft}, UserMessageContentText: message}) + if ack.ExecutionState != "running" { + t.Fatalf("change draft ack execution_state = %q, want running", ack.ExecutionState) + } + return loadDraftPromoteApplyFixture(t, s, sessionID, requestID+"-get", "session_execution/change_draft_artifact", "runecode.protocol.v0.RuneContextChangeDraftArtifact", "change_id") +} + +func runSpecDraftForPromoteApply(t *testing.T, s *Service, sessionID, requestID, message string) draftPromoteApplyFixture { + t.Helper() + mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: requestID, SessionID: sessionID, TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationSpecDraft}, UserMessageContentText: message}) + return loadDraftPromoteApplyFixture(t, s, sessionID, requestID+"-get", "session_execution/spec_draft_artifact", "runecode.protocol.v0.RuneContextSpecDraftArtifact", "spec_id") +} + +func loadDraftPromoteApplyFixture(t *testing.T, s *Service, sessionID, requestID, stepID, schemaID, identityField string) draftPromoteApplyFixture { + t.Helper() + getResp := mustSessionGet(t, s, requestID, sessionID) + if getResp.Session.LatestTurnExecution == nil { + t.Fatalf("latest_turn_execution missing after %s", stepID) + } + exec := getResp.Session.LatestTurnExecution + artifact := requireSessionExecutionLinkedArtifactByStepAndSchema(t, s, exec.PrimaryRunID, stepID, schemaID, "") + draftTextDigest := digestObjectValueFromMap(artifact, "artifact_digest") + return draftPromoteApplyFixture{ + digest: digestForRunStep(t, s, exec.PrimaryRunID, stepID), + changeOrSpecID: stringValueFromMap(artifact, identityField), + draftText: mustArtifactText(t, s, draftTextDigest), + primaryRunID: exec.PrimaryRunID, + } +} + +func seedApprovedImplementationWorkspaceMutationFixture(t *testing.T, s *Service) (string, string, string, string) { + t.Helper() + proposalText := "# CHG-approved-impl\n\n## Summary\nImplemented from approved input set.\n" + tasksText := "# Tasks\n\n- [x] Implement approved workspace mutation path\n" + approvedWorkspaceDigest := artifacts.DigestBytes([]byte("approved-workspace-input")) + approvedMetadataDigest := artifacts.DigestBytes([]byte("approved-metadata-input")) + proposalDigest := putApprovedImplementationMutationArtifactForTest(t, s, map[string]any{ + "target_path": "runecontext/changes/CHG-approved-impl/proposal.md", + "content": proposalText, + "content_digest": digestObject(artifacts.DigestBytes([]byte(proposalText))), + "write_mode": "create", + }) + tasksDigest := putApprovedImplementationMutationArtifactForTest(t, s, map[string]any{ + "target_path": "runecontext/changes/CHG-approved-impl/tasks.md", + "content": tasksText, + "content_digest": digestObject(artifacts.DigestBytes([]byte(tasksText))), + "write_mode": "create", + }) + payload := approvedImplementationInputSetFixture(t, s, []string{approvedWorkspaceDigest, approvedMetadataDigest, proposalDigest, tasksDigest}, []string{proposalDigest}, []string{tasksDigest}) + inputSetDigest, ok := approvedImplementationInputSetDigest(payload) + if !ok { + t.Fatal("approvedImplementationInputSetDigest returned invalid fixture digest") + } + inputSetArtifactDigest := putApprovedImplementationInputSetForTest(t, s, payload) + return inputSetArtifactDigest, inputSetDigest, proposalText, tasksText +} + +func assertDraftPromoteApplyExecution(t *testing.T, s *Service, requestID, sessionID string) { + t.Helper() + post := mustSessionGet(t, s, requestID, sessionID) + if post.Session.LatestTurnExecution == nil || post.Session.LatestTurnExecution.ExecutionState != "completed" { + t.Fatalf("latest turn execution after change promote/apply = %+v, want completed", post.Session.LatestTurnExecution) + } + if post.Session.LatestTurnExecution.WorkflowRouting.WorkflowOperation != sessionWorkflowOperationDraftPromoteApply { + t.Fatalf("latest workflow operation after change promote/apply = %q, want %q", post.Session.LatestTurnExecution.WorkflowRouting.WorkflowOperation, sessionWorkflowOperationDraftPromoteApply) + } + if approvalID := post.Session.LatestTurnExecution.PendingApprovalID; approvalID != "" { + t.Fatalf("pending_approval_id after change promote/apply = %q, want empty", approvalID) + } + if len(post.Session.LatestTurnExecution.LinkedApprovalIDs) == 0 { + t.Fatal("linked_approval_ids empty after change promote/apply") + } + approvalResp := mustApprovalGetResponse(t, s, requestID+"-approval", post.Session.LatestTurnExecution.LinkedApprovalIDs[0]) + if approvalResp.Approval.Status != "consumed" || approvalResp.Approval.BoundScope.ActionKind != policyengine.ActionKindWorkspaceWrite { + t.Fatalf("unexpected promote/apply approval: %+v", approvalResp.Approval) + } +} + +func assertDraftPromoteApplyAuditEvent(t *testing.T, s *Service, draftDigest string) { + t.Helper() + events, err := s.ReadAuditEvents() + if err != nil { + t.Fatalf("ReadAuditEvents returned error: %v", err) + } + if !auditEventContainsValue(events, "runecontext_draft_promote_apply", "draft_artifact_digest", draftDigest) { + t.Fatalf("draft promote/apply audit event missing draft digest %q", draftDigest) + } +} + +func assertApprovedImplementationExecution(t *testing.T, s *Service, requestID, sessionID string) { + t.Helper() + post := mustSessionGet(t, s, requestID, sessionID) + if post.Session.LatestTurnExecution == nil || post.Session.LatestTurnExecution.ExecutionState != "completed" { + t.Fatalf("latest turn execution after approved implementation = %+v, want completed", post.Session.LatestTurnExecution) + } + if post.Session.LatestTurnExecution.WorkflowRouting.WorkflowOperation != sessionWorkflowOperationApprovedImplementation { + t.Fatalf("latest workflow operation = %q, want %q", post.Session.LatestTurnExecution.WorkflowRouting.WorkflowOperation, sessionWorkflowOperationApprovedImplementation) + } + if len(post.Session.LatestTurnExecution.LinkedApprovalIDs) != 2 { + t.Fatalf("linked_approval_ids len = %d, want 2", len(post.Session.LatestTurnExecution.LinkedApprovalIDs)) + } + for _, approvalID := range post.Session.LatestTurnExecution.LinkedApprovalIDs { + approvalResp := mustApprovalGetResponse(t, s, requestID+"-approval-"+sessionExecutionIdentifierToken(approvalID), approvalID) + if approvalResp.Approval.Status != "consumed" || approvalResp.Approval.BoundScope.ActionKind != policyengine.ActionKindWorkspaceWrite { + t.Fatalf("unexpected approved implementation approval: %+v", approvalResp.Approval) + } + } +} + +func assertApprovedImplementationAuditEvent(t *testing.T, s *Service, inputSetArtifactDigest, inputSetDigest string) { + t.Helper() + events, err := s.ReadAuditEvents() + if err != nil { + t.Fatalf("ReadAuditEvents returned error: %v", err) + } + if !auditEventContainsValue(events, "runecontext_approved_implementation_applied", "input_set_artifact_digest", inputSetArtifactDigest) { + t.Fatalf("approved implementation audit event missing input set artifact digest %q", inputSetArtifactDigest) + } + if !auditEventContainsValue(events, "runecontext_approved_implementation_applied", "input_set_digest", inputSetDigest) { + t.Fatalf("approved implementation audit event missing input set digest %q", inputSetDigest) + } +} + +func assertCompletedWorkflowLoopProjectsOwnedLinks(t *testing.T, s *Service, draftDigest, draftRunID string) { + t.Helper() + post := mustSessionGet(t, s, "req-trigger-loop-links-post", "sess-trigger-loop-links") + if post.Session.LatestTurnExecution == nil { + t.Fatal("latest_turn_execution missing after promote/apply") + } + exec := post.Session.LatestTurnExecution + if exec.ExecutionState != "completed" { + t.Fatalf("latest execution_state = %q, want completed", exec.ExecutionState) + } + if len(exec.LinkedApprovalIDs) == 0 { + t.Fatal("latest linked_approval_ids empty after promote/apply") + } + if !strings.Contains(strings.Join(post.Session.LinkedArtifactDigests, ","), draftDigest) { + t.Fatalf("session linked_artifact_digests = %+v, want draft digest %q included", post.Session.LinkedArtifactDigests, draftDigest) + } + if len(post.Session.LinkedApprovalIDs) < len(exec.LinkedApprovalIDs) { + t.Fatalf("session linked_approval_ids = %d, want at least %d", len(post.Session.LinkedApprovalIDs), len(exec.LinkedApprovalIDs)) + } + if !strings.Contains(strings.Join(post.Session.LinkedRunIDs, ","), draftRunID) { + t.Fatalf("session linked_run_ids = %+v, want draft run %q included", post.Session.LinkedRunIDs, draftRunID) + } + if !strings.Contains(strings.Join(post.Session.LinkedRunIDs, ","), exec.PrimaryRunID) { + t.Fatalf("session linked_run_ids = %+v, want promote/apply run %q included", post.Session.LinkedRunIDs, exec.PrimaryRunID) + } +} diff --git a/internal/brokerapi/local_api_session_execution_trigger_side_effects.go b/internal/brokerapi/local_api_session_execution_trigger_side_effects.go index 339a9e02..363375f7 100644 --- a/internal/brokerapi/local_api_session_execution_trigger_side_effects.go +++ b/internal/brokerapi/local_api_session_execution_trigger_side_effects.go @@ -1,37 +1,175 @@ package brokerapi import ( + "context" "fmt" "strings" "github.com/runecode-ai/runecode/internal/artifacts" ) -func (s *Service) reconcileSessionExecutionTriggerSideEffects(requestID string, session artifacts.SessionDurableState, req SessionExecutionTriggerRequest, resp SessionExecutionTriggerResponse) error { +func (s *Service) reconcileSessionExecutionTriggerSideEffects(ctx context.Context, requestID string, session artifacts.SessionDurableState, req SessionExecutionTriggerRequest, resp SessionExecutionTriggerResponse) error { if req.RequestedOperation == "start" { s.auditSessionExecutionTrigger(requestID, req, resp) } - triggerSession, ok := s.SessionState(req.SessionID) + result, runID, err := s.loadSessionExecutionTriggerResult(req.SessionID, resp.TriggerID) + if err != nil { + return err + } + if err := s.appendSessionExecutionStartCheckpointIfNeeded(req, resp.TriggerID, runID); err != nil { + return err + } + if !shouldReconcileStartedExecution(req, result) { + return nil + } + authority, err := s.ensureSessionExecutionRunPlanAuthority(result) + if err != nil { + return err + } + result, err = s.applySessionExecutionWorkflowSideEffects(req.SessionID, result, authority) + if err != nil { + return err + } + return s.bridgeSessionExecutionTriggerToRun(ctx, requestID, result, authority) +} + +func (s *Service) loadSessionExecutionTriggerResult(sessionID, triggerID string) (artifacts.SessionExecutionTriggerAppendResult, string, error) { + triggerSession, ok := s.SessionState(sessionID) if !ok { - return fmt.Errorf("session %q not found", req.SessionID) + return artifacts.SessionExecutionTriggerAppendResult{}, "", fmt.Errorf("session %q not found", sessionID) } - result, ok := sessionExecutionTriggerAppendResultForID(triggerSession, resp.TriggerID) + result, ok := sessionExecutionTriggerAppendResultForID(triggerSession, triggerID) if !ok { - return fmt.Errorf("session execution trigger %q not found", resp.TriggerID) + return artifacts.SessionExecutionTriggerAppendResult{}, "", fmt.Errorf("session execution trigger %q not found", triggerID) } runID := strings.TrimSpace(result.TurnExecution.PrimaryRunID) if runID == "" { runID = strings.TrimSpace(triggerSession.CreatedByRunID) } - if req.RequestedOperation == "start" { - if err := s.appendSessionExecutionStartCheckpoint(req.SessionID, resp.TriggerID, runID, req.UserMessageContentText); err != nil { - return err + return result, runID, nil +} + +func (s *Service) appendSessionExecutionStartCheckpointIfNeeded(req SessionExecutionTriggerRequest, triggerID, runID string) error { + if req.RequestedOperation != "start" { + return nil + } + return s.appendSessionExecutionStartCheckpoint(req.SessionID, triggerID, runID, req.UserMessageContentText) +} + +func shouldReconcileStartedExecution(req SessionExecutionTriggerRequest, result artifacts.SessionExecutionTriggerAppendResult) bool { + return req.RequestedOperation == "start" && strings.TrimSpace(result.TurnExecution.ExecutionState) == "running" +} + +func (s *Service) applySessionExecutionWorkflowSideEffects(sessionID string, result artifacts.SessionExecutionTriggerAppendResult, authority sessionExecutionPlanAuthority) (artifacts.SessionExecutionTriggerAppendResult, error) { + var err error + result, err = s.applySessionExecutionMutationBearingSideEffects(sessionID, result, authority) + if err != nil { + return artifacts.SessionExecutionTriggerAppendResult{}, err + } + return s.applySessionExecutionDraftArtifactSideEffects(sessionID, result, authority) +} + +func (s *Service) applySessionExecutionMutationBearingSideEffects(sessionID string, result artifacts.SessionExecutionTriggerAppendResult, authority sessionExecutionPlanAuthority) (artifacts.SessionExecutionTriggerAppendResult, error) { + var err error + result, err = s.applySessionExecutionDraftPromoteSideEffects(sessionID, result, authority) + if err != nil { + return artifacts.SessionExecutionTriggerAppendResult{}, err + } + return s.applySessionExecutionApprovedImplementationSideEffects(sessionID, result, authority) +} + +func (s *Service) applySessionExecutionDraftPromoteSideEffects(sessionID string, result artifacts.SessionExecutionTriggerAppendResult, authority sessionExecutionPlanAuthority) (artifacts.SessionExecutionTriggerAppendResult, error) { + if strings.TrimSpace(authority.workflowOperation) != sessionWorkflowOperationDraftPromoteApply || len(result.TurnExecution.WorkflowRouting.BoundInputArtifacts) == 0 { + return result, nil + } + approvalID, err := s.applySessionExecutionDraftPromote(result, authority) + if err != nil { + return artifacts.SessionExecutionTriggerAppendResult{}, err + } + if strings.TrimSpace(approvalID) == "" { + return result, nil + } + return s.updateSessionExecutionLinkedApprovals(sessionID, result.TurnExecution.TurnID, append(result.TurnExecution.LinkedApprovalIDs, approvalID)) +} + +func (s *Service) applySessionExecutionApprovedImplementationSideEffects(sessionID string, result artifacts.SessionExecutionTriggerAppendResult, authority sessionExecutionPlanAuthority) (artifacts.SessionExecutionTriggerAppendResult, error) { + if strings.TrimSpace(authority.workflowOperation) != sessionWorkflowOperationApprovedImplementation { + return result, nil + } + linkedApprovalIDs, linkedArtifactDigests, err := s.applySessionExecutionApprovedImplementation(result, authority) + if err != nil { + return artifacts.SessionExecutionTriggerAppendResult{}, err + } + if len(linkedApprovalIDs) > 0 { + result, err = s.updateSessionExecutionLinkedApprovals(sessionID, result.TurnExecution.TurnID, append(result.TurnExecution.LinkedApprovalIDs, linkedApprovalIDs...)) + if err != nil { + return artifacts.SessionExecutionTriggerAppendResult{}, err } } - if err := s.bridgeSessionExecutionTriggerToRun(runID, result); err != nil { - return err + if len(linkedArtifactDigests) == 0 { + return result, nil + } + return s.updateSessionExecutionLinkedArtifacts(sessionID, result.TurnExecution.TurnID, append(result.TurnExecution.LinkedArtifactDigests, linkedArtifactDigests...)) +} + +func (s *Service) applySessionExecutionDraftArtifactSideEffects(sessionID string, result artifacts.SessionExecutionTriggerAppendResult, authority sessionExecutionPlanAuthority) (artifacts.SessionExecutionTriggerAppendResult, error) { + linkedArtifactDigests, err := s.materializeSessionExecutionDraftArtifacts(result, authority) + if err != nil { + return artifacts.SessionExecutionTriggerAppendResult{}, err + } + if len(linkedArtifactDigests) > 0 { + if result, err = s.updateSessionExecutionLinkedArtifacts(sessionID, result.TurnExecution.TurnID, append(result.TurnExecution.LinkedArtifactDigests, linkedArtifactDigests...)); err != nil { + return artifacts.SessionExecutionTriggerAppendResult{}, err + } + } + return result, nil +} + +func (s *Service) updateSessionExecutionLinkedArtifacts(sessionID, turnID string, digests []string) (artifacts.SessionExecutionTriggerAppendResult, error) { + updated, err := s.UpdateSessionTurnExecution(artifacts.SessionTurnExecutionUpdateRequest{ + SessionID: sessionID, + TurnID: turnID, + ExecutionState: "running", + LinkedArtifactDigests: uniqueSortedStrings(digests), + OccurredAt: s.currentTimestamp(), + }) + if err != nil { + return artifacts.SessionExecutionTriggerAppendResult{}, err + } + session, ok := s.SessionState(sessionID) + if !ok { + return artifacts.SessionExecutionTriggerAppendResult{}, fmt.Errorf("session %q not found", sessionID) + } + result, ok := sessionExecutionTriggerAppendResultForID(session, updated.TriggerID) + if !ok { + return artifacts.SessionExecutionTriggerAppendResult{}, fmt.Errorf("session execution trigger for turn %q not found", turnID) + } + result.TurnExecution = updated + return result, nil +} + +func (s *Service) updateSessionExecutionLinkedApprovals(sessionID, turnID string, approvalIDs []string) (artifacts.SessionExecutionTriggerAppendResult, error) { + updated, err := s.UpdateSessionTurnExecution(artifacts.SessionTurnExecutionUpdateRequest{ + SessionID: sessionID, + TurnID: turnID, + ExecutionState: "running", + PendingApprovalID: "", + LinkedApprovalIDs: uniqueSortedStrings(approvalIDs), + OccurredAt: s.currentTimestamp(), + }) + if err != nil { + return artifacts.SessionExecutionTriggerAppendResult{}, err + } + session, ok := s.SessionState(sessionID) + if !ok { + return artifacts.SessionExecutionTriggerAppendResult{}, fmt.Errorf("session %q not found", sessionID) + } + result, ok := sessionExecutionTriggerAppendResultForID(session, updated.TriggerID) + if !ok { + return artifacts.SessionExecutionTriggerAppendResult{}, fmt.Errorf("session execution trigger for turn %q not found", turnID) } - return nil + result.TurnExecution = updated + return result, nil } func (s *Service) nextSessionInteractionSequence(requestID, sessionID string) (int64, *ErrorResponse) { diff --git a/internal/brokerapi/local_api_session_execution_trigger_validation_approved_impl.go b/internal/brokerapi/local_api_session_execution_trigger_validation_approved_impl.go index a0d1f00b..28c3c5c9 100644 --- a/internal/brokerapi/local_api_session_execution_trigger_validation_approved_impl.go +++ b/internal/brokerapi/local_api_session_execution_trigger_validation_approved_impl.go @@ -2,6 +2,7 @@ package brokerapi import ( "encoding/json" + "fmt" "strings" "github.com/runecode-ai/runecode/internal/artifacts" @@ -9,8 +10,14 @@ import ( "github.com/runecode-ai/runecode/internal/trustpolicy" ) +type approvedImplementationInputSetState struct { + decoded map[string]any + inputSetArtifactDigest string + inputSetDigest string +} + func (s *Service) validateApprovedImplementationRouting(requestID string, routing *SessionWorkflowPackRouting) *ErrorResponse { - inputSetDigest := "" + inputSetArtifactDigest := "" inputSetCount := 0 for _, artifact := range routing.BoundInputArtifacts { if strings.TrimSpace(artifact.ArtifactRef) != "implementation_input_set" { @@ -20,57 +27,89 @@ func (s *Service) validateApprovedImplementationRouting(requestID string, routin if inputSetCount > 1 { return sessionExecutionTriggerValidationError(s, requestID, "workflow_routing approved_change_implementation allows exactly one implementation_input_set artifact binding") } - inputSetDigest = strings.TrimSpace(artifact.ArtifactDigest) + inputSetArtifactDigest = strings.TrimSpace(artifact.ArtifactDigest) } - if inputSetDigest == "" { + if inputSetArtifactDigest == "" { return sessionExecutionTriggerValidationError(s, requestID, "workflow_routing approved_change_implementation requires implementation_input_set artifact binding") } - return s.validateApprovedImplementationIdentityTuple(requestID, inputSetDigest) + return s.validateApprovedImplementationIdentityTuple(requestID, inputSetArtifactDigest) } -func (s *Service) validateApprovedImplementationIdentityTuple(requestID, inputSetDigest string) *ErrorResponse { - decoded, errResp := s.decodeApprovedImplementationInputSet(requestID, inputSetDigest) +func (s *Service) validateApprovedImplementationIdentityTuple(requestID, inputSetArtifactDigest string) *ErrorResponse { + inputSet, errResp := s.decodeApprovedImplementationInputSet(requestID, inputSetArtifactDigest) if errResp != nil { return errResp } - if !matchesBoundInputSetDigest(decoded, inputSetDigest) { - return sessionExecutionTriggerValidationError(s, requestID, "implementation_input_set input_set_digest does not match bound artifact digest") - } - if errResp := validateApprovedImplementationCatalogBinding(s, requestID, decoded); errResp != nil { + if errResp := validateApprovedImplementationCatalogBinding(s, requestID, inputSet.decoded); errResp != nil { return errResp } project, errResp := s.requireSupportedProjectSubstrateForSessionExecution(requestID) if errResp != nil { return errResp } - validatedDigest, ok := digestIdentityFromApprovedImplementationField(decoded, "validated_project_substrate_digest") + validatedDigest, ok := digestIdentityFromApprovedImplementationField(inputSet.decoded, "validated_project_substrate_digest") if !ok || strings.TrimSpace(validatedDigest) != strings.TrimSpace(sessionExecutionBoundDigest(project)) { return sessionExecutionTriggerValidationError(s, requestID, "implementation_input_set validated_project_substrate_digest drift detected") } return nil } -func (s *Service) decodeApprovedImplementationInputSet(requestID, inputSetDigest string) (map[string]any, *ErrorResponse) { - payload, err := s.readArtifactPayload(inputSetDigest) +func (s *Service) decodeApprovedImplementationInputSet(requestID, inputSetArtifactDigest string) (approvedImplementationInputSetState, *ErrorResponse) { + payload, err := s.readArtifactPayloadVerified(inputSetArtifactDigest) if err != nil { - return nil, sessionExecutionTriggerValidationError(s, requestID, "workflow_routing implementation_input_set artifact is unreadable") + return approvedImplementationInputSetState{}, sessionExecutionTriggerValidationError(s, requestID, "workflow_routing implementation_input_set artifact is unreadable") } if err := artifacts.ValidateObjectPayloadAgainstSchema(payload, "objects/RuneContextApprovedImplementationInputSet.schema.json"); err != nil { - return nil, sessionExecutionTriggerValidationError(s, requestID, "workflow_routing implementation_input_set payload is invalid") + return approvedImplementationInputSetState{}, sessionExecutionTriggerValidationError(s, requestID, "workflow_routing implementation_input_set payload is invalid") } var decoded map[string]any if err := json.Unmarshal(payload, &decoded); err != nil { - return nil, sessionExecutionTriggerValidationError(s, requestID, "workflow_routing implementation_input_set payload decode failed") + return approvedImplementationInputSetState{}, sessionExecutionTriggerValidationError(s, requestID, "workflow_routing implementation_input_set payload decode failed") + } + inputSetDigest, ok := approvedImplementationInputSetDigest(decoded) + if !ok { + return approvedImplementationInputSetState{}, sessionExecutionTriggerValidationError(s, requestID, "implementation_input_set input_set_digest is invalid") } - return decoded, nil + recomputedInputSetDigest, err := recomputeApprovedImplementationInputSetDigest(decoded) + if err != nil { + return approvedImplementationInputSetState{}, sessionExecutionTriggerValidationError(s, requestID, "implementation_input_set input_set_digest recompute failed") + } + if strings.TrimSpace(inputSetDigest) != strings.TrimSpace(recomputedInputSetDigest) { + return approvedImplementationInputSetState{}, sessionExecutionTriggerValidationError(s, requestID, "implementation_input_set input_set_digest drift detected") + } + return approvedImplementationInputSetState{ + decoded: decoded, + inputSetArtifactDigest: strings.TrimSpace(inputSetArtifactDigest), + inputSetDigest: strings.TrimSpace(recomputedInputSetDigest), + }, nil } -func matchesBoundInputSetDigest(decoded map[string]any, inputSetDigest string) bool { +func approvedImplementationInputSetDigest(decoded map[string]any) (string, bool) { inputSetField, ok := digestIdentityFromApprovedImplementationField(decoded, "input_set_digest") if !ok { - return false + return "", false + } + return strings.TrimSpace(inputSetField), true +} + +func recomputeApprovedImplementationInputSetDigest(decoded map[string]any) (string, error) { + if decoded == nil { + return "", fmt.Errorf("payload must be an object") + } + payloadWithoutDigest := make(map[string]any, len(decoded)) + for key, value := range decoded { + payloadWithoutDigest[key] = value + } + delete(payloadWithoutDigest, "input_set_digest") + raw, err := json.Marshal(payloadWithoutDigest) + if err != nil { + return "", fmt.Errorf("marshal canonical input set body: %w", err) + } + canonical, err := artifacts.CanonicalizeJSONBytes(raw) + if err != nil { + return "", fmt.Errorf("canonicalize input set body: %w", err) } - return strings.TrimSpace(inputSetField) == strings.TrimSpace(inputSetDigest) + return artifacts.DigestBytes(canonical), nil } func validateApprovedImplementationCatalogBinding(s *Service, requestID string, decoded map[string]any) *ErrorResponse { diff --git a/internal/brokerapi/local_api_session_execution_trigger_validation_test.go b/internal/brokerapi/local_api_session_execution_trigger_validation_test.go index 3f9d378e..2385028b 100644 --- a/internal/brokerapi/local_api_session_execution_trigger_validation_test.go +++ b/internal/brokerapi/local_api_session_execution_trigger_validation_test.go @@ -1,26 +1,64 @@ package brokerapi import ( + "encoding/json" + "reflect" "strings" "testing" + + "github.com/runecode-ai/runecode/internal/artifacts" ) -func TestMatchesBoundInputSetDigestAcceptsDigestObjectIdentity(t *testing.T) { - bound := "sha256:" + strings.Repeat("a", 64) +func TestApprovedImplementationInputSetDigestAcceptsDigestObjectIdentity(t *testing.T) { + want := "sha256:" + strings.Repeat("a", 64) decoded := map[string]any{ - "input_set_digest": digestObject(bound), + "input_set_digest": digestObject(want), } - if !matchesBoundInputSetDigest(decoded, bound) { - t.Fatal("matchesBoundInputSetDigest returned false, want true") + if got, ok := approvedImplementationInputSetDigest(decoded); !ok || got != want { + t.Fatalf("approvedImplementationInputSetDigest = (%q, %v), want (%q, true)", got, ok, want) } } -func TestMatchesBoundInputSetDigestRejectsMalformedDigestObject(t *testing.T) { +func TestApprovedImplementationInputSetDigestRejectsMalformedDigestObject(t *testing.T) { decoded := map[string]any{ "input_set_digest": map[string]any{"hash_alg": "sha512", "hash": "abc"}, } - if matchesBoundInputSetDigest(decoded, "sha256:"+strings.Repeat("a", 64)) { - t.Fatal("matchesBoundInputSetDigest returned true for malformed digest object") + if _, ok := approvedImplementationInputSetDigest(decoded); ok { + t.Fatal("approvedImplementationInputSetDigest returned ok for malformed digest object") + } +} + +func TestRecomputeApprovedImplementationInputSetDigestExcludesEmbeddedDigestField(t *testing.T) { + decoded := map[string]any{ + "schema_id": "runecode.protocol.v0.RuneContextApprovedImplementationInputSet", + "schema_version": "0.1.0", + "approval_profile": "moderate", + "input_set_digest": digestObject("sha256:" + strings.Repeat("f", 64)), + "approved_input_digests": []any{digestObject("sha256:" + strings.Repeat("a", 64))}, + } + got, err := recomputeApprovedImplementationInputSetDigest(decoded) + if err != nil { + t.Fatalf("recomputeApprovedImplementationInputSetDigest returned error: %v", err) + } + clone := map[string]any{} + for key, value := range decoded { + clone[key] = value + } + delete(clone, "input_set_digest") + raw, err := json.Marshal(clone) + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + canonical, err := artifacts.CanonicalizeJSONBytes(raw) + if err != nil { + t.Fatalf("CanonicalizeJSONBytes returned error: %v", err) + } + want := artifacts.DigestBytes(canonical) + if got != want { + t.Fatalf("recomputeApprovedImplementationInputSetDigest = %q, want %q", got, want) + } + if !reflect.DeepEqual(decoded["input_set_digest"], digestObject("sha256:"+strings.Repeat("f", 64))) { + t.Fatal("recomputeApprovedImplementationInputSetDigest mutated input payload") } } diff --git a/internal/brokerapi/local_api_session_execution_trigger_verification_smoke_test.go b/internal/brokerapi/local_api_session_execution_trigger_verification_smoke_test.go new file mode 100644 index 00000000..d4ed87e6 --- /dev/null +++ b/internal/brokerapi/local_api_session_execution_trigger_verification_smoke_test.go @@ -0,0 +1,266 @@ +package brokerapi + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/trustpolicy" +) + +func TestSessionExecutionTriggerVerificationSmokePathProducesInspectableEvidence(t *testing.T) { + s, repoRoot := newVerificationSmokeService(t) + changeDraftDigest, specDraftDigest, changeID := runVerificationSmokeDraftAndPromoteFlow(t, s, repoRoot) + inputSetArtifactDigest, inputSetDigest, finalExec := runVerificationSmokeApprovedImplementation(t, s, repoRoot, changeID) + assertVerificationSmokeRunAndArtifactSurfaces(t, s, finalExec.PrimaryRunID) + assertVerificationSmokeAuditEvidenceSurfaces(t, s, changeDraftDigest, specDraftDigest, inputSetArtifactDigest, inputSetDigest) +} + +func newVerificationSmokeService(t *testing.T) (*Service, string) { + t.Helper() + repoRoot := t.TempDir() + writeProjectSubstrateAnchors(t, repoRoot, "0.1.0-alpha.14", "verified", "runecontext") + storeRoot := t.TempDir() + ledgerRoot := t.TempDir() + if err := seedLedgerForBrokerSurfaceTest(ledgerRoot); err != nil { + t.Fatalf("seedLedgerForBrokerSurfaceTest returned error: %v", err) + } + s, err := NewServiceWithConfig(storeRoot, ledgerRoot, APIConfig{RepositoryRoot: repoRoot}) + if err != nil { + t.Fatalf("NewServiceWithConfig returned error: %v", err) + } + s.sessionExecutionRunner = launchSessionExecutionRunnerCompleteInProcessForTests + seedSessionRuntimeFactsForOpsTest(t, s, "run-verification-smoke", "sess-verification-smoke") + return s, repoRoot +} + +func runVerificationSmokeDraftAndPromoteFlow(t *testing.T, s *Service, repoRoot string) (string, string, string) { + t.Helper() + changeDraftDigest, changeID, changeProposalText := verificationSmokeChangeDraft(t, s) + applyVerificationSmokeDraft(t, s, "change_draft_artifact", changeDraftDigest, "Apply reviewed change draft") + requireFileContents(t, repoRoot, filepath.ToSlash(filepath.Join("runecontext/changes", changeID, "proposal.md")), changeProposalText) + + specDraftDigest, specID, specDraftText := verificationSmokeSpecDraft(t, s) + applyVerificationSmokeDraft(t, s, "spec_draft_artifact", specDraftDigest, "Apply reviewed spec draft") + requireFileContents(t, repoRoot, filepath.ToSlash(filepath.Join("runecontext/specs", specID+".md")), specDraftText) + + return changeDraftDigest, specDraftDigest, changeID +} + +func applyVerificationSmokeDraft(t *testing.T, s *Service, artifactRef, digest, message string) { + t.Helper() + mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: verificationSmokePromoteRequestID(artifactRef), SessionID: "sess-verification-smoke", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationDraftPromoteApply, BoundInputArtifacts: []SessionWorkflowPackBoundInputArtifact{{ArtifactRef: artifactRef, ArtifactDigest: digest}}}, UserMessageContentText: message}) +} + +func verificationSmokePromoteRequestID(artifactRef string) string { + if artifactRef == "change_draft_artifact" { + return "req-verification-smoke-change-promote" + } + return "req-verification-smoke-spec-promote" +} + +func verificationSmokeChangeDraft(t *testing.T, s *Service) (string, string, string) { + t.Helper() + changeAck := mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-verification-smoke-change-draft", SessionID: "sess-verification-smoke", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationChangeDraft}, UserMessageContentText: "Verification smoke change draft"}) + if changeAck.ExecutionState != "running" { + t.Fatalf("change draft ack execution_state = %q, want running", changeAck.ExecutionState) + } + changeGet := mustSessionGet(t, s, "req-verification-smoke-change-draft-get", "sess-verification-smoke") + if changeGet.Session.LatestTurnExecution == nil { + t.Fatal("latest_turn_execution missing after change draft") + } + changeExec := changeGet.Session.LatestTurnExecution + changeArtifact := requireSessionExecutionLinkedArtifactByStepAndSchema(t, s, changeExec.PrimaryRunID, "session_execution/change_draft_artifact", "runecode.protocol.v0.RuneContextChangeDraftArtifact", "") + changeDraftDigest := digestForRunStep(t, s, changeExec.PrimaryRunID, "session_execution/change_draft_artifact") + changeID := stringValueFromMap(changeArtifact, "change_id") + changeProposalDigest := digestObjectValueFromMap(changeArtifact, "artifact_digest") + changeProposalText := mustArtifactText(t, s, changeProposalDigest) + return changeDraftDigest, changeID, changeProposalText +} + +func verificationSmokeSpecDraft(t *testing.T, s *Service) (string, string, string) { + t.Helper() + specAck := mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-verification-smoke-spec-draft", SessionID: "sess-verification-smoke", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationSpecDraft}, UserMessageContentText: "Verification smoke spec draft"}) + if specAck.ExecutionState != "running" { + t.Fatalf("spec draft ack execution_state = %q, want running", specAck.ExecutionState) + } + specGet := mustSessionGet(t, s, "req-verification-smoke-spec-draft-get", "sess-verification-smoke") + if specGet.Session.LatestTurnExecution == nil { + t.Fatal("latest_turn_execution missing after spec draft") + } + specExec := specGet.Session.LatestTurnExecution + specArtifact := requireSessionExecutionLinkedArtifactByStepAndSchema(t, s, specExec.PrimaryRunID, "session_execution/spec_draft_artifact", "runecode.protocol.v0.RuneContextSpecDraftArtifact", "") + specDraftDigest := digestForRunStep(t, s, specExec.PrimaryRunID, "session_execution/spec_draft_artifact") + specID := stringValueFromMap(specArtifact, "spec_id") + specTextDigest := digestObjectValueFromMap(specArtifact, "artifact_digest") + specDraftText := mustArtifactText(t, s, specTextDigest) + return specDraftDigest, specID, specDraftText +} + +func runVerificationSmokeApprovedImplementation(t *testing.T, s *Service, repoRoot, changeID string) (string, string, *SessionTurnExecution) { + t.Helper() + proposalText := fmt.Sprintf("# %s\n\n## Verification smoke\nWorkspace mutation applied from approved input set.\n", changeID) + tasksText := "# Tasks\n\n- [x] Verification smoke implementation path applied\n" + approvedWorkspaceDigest := digestForVerificationSmokeInput("verification-smoke-approved-workspace") + approvedMetadataDigest := digestForVerificationSmokeInput("verification-smoke-approved-metadata") + proposalMutationDigest := putApprovedImplementationMutationArtifactForTest(t, s, map[string]any{"target_path": filepath.ToSlash(filepath.Join("runecontext/changes", changeID, "proposal.md")), "content": proposalText, "content_digest": digestObject(digestForVerificationSmokeInput(proposalText)), "write_mode": "update"}) + tasksMutationDigest := putApprovedImplementationMutationArtifactForTest(t, s, map[string]any{"target_path": filepath.ToSlash(filepath.Join("runecontext/changes", changeID, "tasks.md")), "content": tasksText, "content_digest": digestObject(digestForVerificationSmokeInput(tasksText)), "write_mode": "create"}) + payload := approvedImplementationInputSetFixture(t, s, []string{approvedWorkspaceDigest, approvedMetadataDigest, proposalMutationDigest, tasksMutationDigest}, []string{proposalMutationDigest}, []string{tasksMutationDigest}) + inputSetDigest, ok := approvedImplementationInputSetDigest(payload) + if !ok { + t.Fatal("approvedImplementationInputSetDigest returned invalid verification fixture digest") + } + inputSetArtifactDigest := putApprovedImplementationInputSetForTest(t, s, payload) + implAck := mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-verification-smoke-approved-implementation", SessionID: "sess-verification-smoke", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationApprovedImplementation, BoundInputArtifacts: []SessionWorkflowPackBoundInputArtifact{{ArtifactRef: "implementation_input_set", ArtifactDigest: inputSetArtifactDigest}}}, UserMessageContentText: "Apply approved implementation"}) + if implAck.ExecutionState != "running" { + t.Fatalf("approved implementation ack execution_state = %q, want running", implAck.ExecutionState) + } + requireFileContents(t, repoRoot, filepath.ToSlash(filepath.Join("runecontext/changes", changeID, "proposal.md")), proposalText) + requireFileContents(t, repoRoot, filepath.ToSlash(filepath.Join("runecontext/changes", changeID, "tasks.md")), tasksText) + post := mustSessionGet(t, s, "req-verification-smoke-post", "sess-verification-smoke") + if post.Session.LatestTurnExecution == nil { + t.Fatal("latest_turn_execution missing after approved implementation") + } + finalExec := post.Session.LatestTurnExecution + if got := finalExec.WorkflowRouting.WorkflowOperation; got != sessionWorkflowOperationApprovedImplementation { + t.Fatalf("latest workflow_operation = %q, want %q", got, sessionWorkflowOperationApprovedImplementation) + } + if finalExec.ExecutionState != "completed" { + t.Fatalf("latest execution_state = %q, want completed", finalExec.ExecutionState) + } + if len(finalExec.LinkedApprovalIDs) < 2 { + t.Fatalf("linked_approval_ids len = %d, want at least 2", len(finalExec.LinkedApprovalIDs)) + } + return inputSetArtifactDigest, inputSetDigest, finalExec +} + +func digestForVerificationSmokeInput(value string) string { + return artifacts.DigestBytes([]byte(value)) +} + +func assertVerificationSmokeRunAndArtifactSurfaces(t *testing.T, s *Service, runID string) { + t.Helper() + runListResp, errResp := s.HandleRunList(context.Background(), RunListRequest{SchemaID: "runecode.protocol.v0.RunListRequest", SchemaVersion: "0.1.0", RequestID: "req-verification-smoke-run-list", Limit: 20}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleRunList returned error: %+v", errResp) + } + if len(runListResp.Runs) < 5 { + t.Fatalf("run list len = %d, want at least 5 workflow runs", len(runListResp.Runs)) + } + runGet, errResp := s.HandleRunGet(context.Background(), RunGetRequest{SchemaID: "runecode.protocol.v0.RunGetRequest", SchemaVersion: "0.1.0", RequestID: "req-verification-smoke-run-get", RunID: runID}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleRunGet returned error: %+v", errResp) + } + if got := runGet.Run.Summary.WorkflowKind; got != "builtin_rc_approved_implementation_v0" { + t.Fatalf("run summary workflow_kind = %q, want builtin_rc_approved_implementation_v0", got) + } + artifactListResp, errResp := s.HandleArtifactListV0(context.Background(), LocalArtifactListRequest{SchemaID: "runecode.protocol.v0.ArtifactListRequest", SchemaVersion: "0.1.0", RequestID: "req-verification-smoke-artifact-list", RunID: runID, Limit: 20}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleArtifactListV0 returned error: %+v", errResp) + } + if len(artifactListResp.Artifacts) == 0 { + t.Fatal("artifact list empty for final workflow run") + } +} + +func assertVerificationSmokeAuditEvidenceSurfaces(t *testing.T, s *Service, changeDraftDigest, specDraftDigest, inputSetArtifactDigest, inputSetDigest string) { + t.Helper() + auditSurface, err := s.LatestAuditVerificationSurface(50) + if err != nil { + t.Fatalf("LatestAuditVerificationSurface returned error: %v", err) + } + if len(auditSurface.Views) == 0 { + t.Fatal("latest audit verification surface views empty") + } + recordDigest := auditSurface.Views[0].RecordDigest + assertVerificationSmokeAuditRecordEvidence(t, s, recordDigest) + assertVerificationSmokeAuditSnapshotEvidence(t, s) + assertVerificationSmokeOfflineBundleVerification(t, s) + assertVerificationSmokeAuditEvents(t, s, changeDraftDigest, specDraftDigest, inputSetArtifactDigest, inputSetDigest) +} + +func assertVerificationSmokeAuditRecordEvidence(t *testing.T, s *Service, recordDigest trustpolicy.Digest) { + t.Helper() + recordGetResp, errResp := s.HandleAuditRecordGet(context.Background(), AuditRecordGetRequest{SchemaID: "runecode.protocol.v0.AuditRecordGetRequest", SchemaVersion: "0.1.0", RequestID: "req-verification-smoke-audit-record-get", RecordDigest: recordDigest}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleAuditRecordGet returned error: %+v", errResp) + } + if recordGetResp.Record.RecordFamily == "" { + t.Fatal("audit record detail missing record_family") + } + inclusionResp, errResp := s.HandleAuditRecordInclusionGet(context.Background(), AuditRecordInclusionGetRequest{SchemaID: "runecode.protocol.v0.AuditRecordInclusionGetRequest", SchemaVersion: "0.1.0", RequestID: "req-verification-smoke-audit-inclusion-get", RecordDigest: recordDigest}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleAuditRecordInclusionGet returned error: %+v", errResp) + } + if inclusionResp.Inclusion.SegmentID == "" { + t.Fatal("audit inclusion missing segment_id") + } +} + +func assertVerificationSmokeAuditSnapshotEvidence(t *testing.T, s *Service) { + t.Helper() + snapshotResp, errResp := s.HandleAuditEvidenceSnapshotGet(context.Background(), AuditEvidenceSnapshotGetRequest{SchemaID: "runecode.protocol.v0.AuditEvidenceSnapshotGetRequest", SchemaVersion: "0.1.0", RequestID: "req-verification-smoke-audit-snapshot"}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleAuditEvidenceSnapshotGet returned error: %+v", errResp) + } + if len(snapshotResp.Snapshot.SegmentSealDigests) == 0 { + t.Fatal("audit evidence snapshot missing segment_seal_digests") + } +} + +func assertVerificationSmokeOfflineBundleVerification(t *testing.T, s *Service) { + t.Helper() + offlineVerifyResp := exportAndVerifyWorkflowSmokeBundle(t, s) + if offlineVerifyResp.Verification.VerificationStatus == "" { + t.Fatal("offline verification status empty") + } + if len(offlineVerifyResp.Verification.VerificationReports) == 0 { + t.Fatal("offline verification reports empty") + } +} + +func assertVerificationSmokeAuditEvents(t *testing.T, s *Service, changeDraftDigest, specDraftDigest, inputSetArtifactDigest, inputSetDigest string) { + t.Helper() + events, err := s.ReadAuditEvents() + if err != nil { + t.Fatalf("ReadAuditEvents returned error: %v", err) + } + if !auditEventContainsValue(events, "runecontext_draft_promote_apply", "draft_artifact_digest", changeDraftDigest) { + t.Fatalf("draft promote/apply audit event missing change draft digest %q", changeDraftDigest) + } + if !auditEventContainsValue(events, "runecontext_draft_promote_apply", "draft_artifact_digest", specDraftDigest) { + t.Fatalf("draft promote/apply audit event missing spec draft digest %q", specDraftDigest) + } + if !auditEventContainsValue(events, "runecontext_approved_implementation_applied", "input_set_artifact_digest", inputSetArtifactDigest) { + t.Fatalf("approved implementation audit event missing input set artifact digest %q", inputSetArtifactDigest) + } + if !auditEventContainsValue(events, "runecontext_approved_implementation_applied", "input_set_digest", inputSetDigest) { + t.Fatalf("approved implementation audit event missing input set digest %q", inputSetDigest) + } +} + +func exportAndVerifyWorkflowSmokeBundle(t *testing.T, s *Service) AuditEvidenceBundleOfflineVerifyResponse { + t.Helper() + exportReq := AuditEvidenceBundleExportRequest{SchemaID: "runecode.protocol.v0.AuditEvidenceBundleExportRequest", SchemaVersion: "0.1.0", RequestID: "req-verification-smoke-bundle-export", Scope: AuditEvidenceBundleScope{ScopeKind: "run", RunID: "run-1"}, ExportProfile: "external_relying_party_minimal", CreatedByTool: AuditEvidenceBundleToolIdentity{ToolName: "runecode-broker", ToolVersion: "0.0.0-dev"}, DisclosurePosture: AuditEvidenceBundleDisclosurePosture{Posture: "digest_metadata_only", SelectiveDisclosureApplied: true}, ArchiveFormat: "tar"} + exportEvents, errResp := s.HandleAuditEvidenceBundleExport(context.Background(), exportReq, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleAuditEvidenceBundleExport returned error: %+v", errResp) + } + archiveBytes := gatherAuditBundleExportBytes(t, exportEvents) + if len(archiveBytes) == 0 { + t.Fatal("bundle export archive bytes empty") + } + dir := canonicalTempDir(t) + bundlePath := filepath.Join(dir, "verification-smoke-bundle.tar") + if err := os.WriteFile(bundlePath, archiveBytes, 0o600); err != nil { + t.Fatalf("WriteFile(bundlePath) returned error: %v", err) + } + offlineVerifyResp, errResp := s.HandleAuditEvidenceBundleOfflineVerify(context.Background(), AuditEvidenceBundleOfflineVerifyRequest{SchemaID: "runecode.protocol.v0.AuditEvidenceBundleOfflineVerifyRequest", SchemaVersion: "0.1.0", RequestID: "req-verification-smoke-bundle-offline-verify", BundlePath: bundlePath, ArchiveFormat: "tar"}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleAuditEvidenceBundleOfflineVerify returned error: %+v", errResp) + } + return offlineVerifyResp +} diff --git a/internal/brokerapi/local_api_session_ops.go b/internal/brokerapi/local_api_session_ops.go index dd9e5453..98e616b4 100644 --- a/internal/brokerapi/local_api_session_ops.go +++ b/internal/brokerapi/local_api_session_ops.go @@ -133,7 +133,7 @@ func (s *Service) sessionDetail(sessionID string) (SessionDetail, bool, error) { if !ok { return SessionDetail{}, false, nil } - detail := buildSessionDetailFromState(summary, state.TranscriptTurns, runsBySession[sessionID], approvalsBySession[sessionID], artifactsBySession[sessionID], auditBySession[sessionID]) + detail := buildSessionDetailFromState(summary, state.TranscriptTurns, runsBySession[sessionID], approvalsBySession[sessionID], artifactsBySession[sessionID], auditBySession[sessionID], state.TurnExecutions) currentExecution, latestExecution, pendingExecutions := currentAndLatestSessionTurnExecution(state.TurnExecutions) detail.CurrentTurnExecution = currentExecution detail.LatestTurnExecution = latestExecution diff --git a/internal/brokerapi/local_api_session_ops_test.go b/internal/brokerapi/local_api_session_ops_test.go index 0be7bae2..13cea889 100644 --- a/internal/brokerapi/local_api_session_ops_test.go +++ b/internal/brokerapi/local_api_session_ops_test.go @@ -44,6 +44,18 @@ func TestSessionListIncludesRuntimeDerivedSessionWithoutArtifacts(t *testing.T) } } +func TestSessionGetUnionsCompletedExecutionLinksIntoInspectableSessionDetail(t *testing.T) { + repoRoot := t.TempDir() + writeProjectSubstrateAnchors(t, repoRoot, "0.1.0-alpha.14", "verified", "runecontext") + s := newBrokerAPIServiceForTests(t, APIConfig{RepositoryRoot: repoRoot}) + s.sessionExecutionRunner = launchSessionExecutionRunnerCompleteInProcessForTests + seedSessionRuntimeFactsForOpsTest(t, s, "run-session-link-union", "sess-link-union") + + draftExec := triggerSessionLinkUnionDraft(t, s) + getResp := triggerSessionLinkUnionApplyAndGet(t, s, draftExec) + assertSessionLinkUnionProjection(t, getResp, draftExec.PrimaryRunID) +} + func TestSessionGetNotFoundUsesSessionSpecificCode(t *testing.T) { s := newBrokerAPIServiceForTests(t, APIConfig{}) _, errResp := s.HandleSessionGet(context.Background(), SessionGetRequest{SchemaID: "runecode.protocol.v0.SessionGetRequest", SchemaVersion: "0.1.0", RequestID: "req-session-missing", SessionID: "sess-missing"}, RequestContext{}) @@ -212,6 +224,49 @@ func assertRestartSessionSequence(t *testing.T, ack SessionSendMessageResponse, } } +func triggerSessionLinkUnionDraft(t *testing.T, s *Service) *SessionTurnExecution { + t.Helper() + mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-link-union-draft", SessionID: "sess-link-union", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationChangeDraft}, UserMessageContentText: "session link union draft"}) + draftGet := mustSessionGet(t, s, "req-session-link-union-draft-get", "sess-link-union") + if draftGet.Session.LatestTurnExecution == nil { + t.Fatal("latest_turn_execution missing after draft") + } + return draftGet.Session.LatestTurnExecution +} + +func triggerSessionLinkUnionApplyAndGet(t *testing.T, s *Service, draftExec *SessionTurnExecution) SessionGetResponse { + t.Helper() + draftDigest := digestForRunStep(t, s, draftExec.PrimaryRunID, "session_execution/change_draft_artifact") + mustSessionExecutionTrigger(t, s, SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "req-session-link-union-apply", SessionID: "sess-link-union", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: sessionWorkflowOperationDraftPromoteApply, BoundInputArtifacts: []SessionWorkflowPackBoundInputArtifact{{ArtifactRef: "change_draft_artifact", ArtifactDigest: draftDigest}}}, UserMessageContentText: "session link union apply"}) + return mustSessionGet(t, s, "req-session-link-union-get", "sess-link-union") +} + +func assertSessionLinkUnionProjection(t *testing.T, getResp SessionGetResponse, draftRunID string) { + t.Helper() + if getResp.Session.LatestTurnExecution == nil { + t.Fatal("latest_turn_execution missing after promote/apply") + } + latest := getResp.Session.LatestTurnExecution + if latest.ExecutionState != "completed" { + t.Fatalf("latest execution_state = %q, want completed", latest.ExecutionState) + } + assertSessionContainsAllLinks(t, "linked_approval_ids", getResp.Session.LinkedApprovalIDs, latest.LinkedApprovalIDs) + assertSessionContainsAllLinks(t, "linked_artifact_digests", getResp.Session.LinkedArtifactDigests, latest.LinkedArtifactDigests) + assertSessionContainsAllLinks(t, "linked_run_ids", getResp.Session.LinkedRunIDs, []string{draftRunID, latest.PrimaryRunID}) + if latest.WorkflowRouting.WorkflowOperation != sessionWorkflowOperationDraftPromoteApply { + t.Fatalf("latest workflow_operation = %q, want %q", latest.WorkflowRouting.WorkflowOperation, sessionWorkflowOperationDraftPromoteApply) + } +} + +func assertSessionContainsAllLinks(t *testing.T, label string, sessionValues, expectedValues []string) { + t.Helper() + for _, value := range expectedValues { + if !containsStringLocal(sessionValues, value) { + t.Fatalf("session %s = %+v, want %q", label, sessionValues, value) + } + } +} + func TestBuildSessionTranscriptTurnsCapsToSchemaLimits(t *testing.T) { summary := SessionSummary{TurnCount: 3000, UpdatedAt: "2026-01-01T00:00:00Z", LastActivityPreview: "preview"} runs := map[string]struct{}{} @@ -407,3 +462,12 @@ func assertSessionGetLastMessageContent(t *testing.T, resp SessionGetResponse, w t.Fatalf("last message content_text = %q, want %q", lastMessage.ContentText, wantContent) } } + +func containsStringLocal(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/internal/brokerapi/local_api_session_projection_helpers.go b/internal/brokerapi/local_api_session_projection_helpers.go index d75620bc..1168c340 100644 --- a/internal/brokerapi/local_api_session_projection_helpers.go +++ b/internal/brokerapi/local_api_session_projection_helpers.go @@ -201,10 +201,14 @@ func sortSessionSummaries(items []SessionSummary, order string) { } func buildSessionDetail(summary SessionSummary, runs, approvals, artifactsByDigest, auditRecordDigests map[string]struct{}) SessionDetail { - return buildSessionDetailFromState(summary, nil, runs, approvals, artifactsByDigest, auditRecordDigests) + return buildSessionDetailFromState(summary, nil, runs, approvals, artifactsByDigest, auditRecordDigests, nil) } -func buildSessionDetailFromState(summary SessionSummary, transcriptTurns []artifacts.SessionTranscriptTurnDurableState, runs, approvals, artifactsByDigest, auditRecordDigests map[string]struct{}) SessionDetail { +func buildSessionDetailFromState(summary SessionSummary, transcriptTurns []artifacts.SessionTranscriptTurnDurableState, runs, approvals, artifactsByDigest, auditRecordDigests map[string]struct{}, executions []artifacts.SessionTurnExecutionDurableState) SessionDetail { + runs = sessionDetailLinkedRunIndex(runs, executions) + approvals = sessionDetailLinkedApprovalIndex(approvals, executions) + artifactsByDigest = sessionDetailLinkedArtifactIndex(artifactsByDigest, executions) + auditRecordDigests = sessionDetailLinkedAuditIndex(auditRecordDigests, executions) projectedTurns := buildSessionTranscriptTurnsFromDurable(transcriptTurns) if len(projectedTurns) == 0 { projectedTurns = buildSessionTranscriptTurns(summary.Identity.SessionID, summary, runs, approvals, artifactsByDigest, auditRecordDigests) diff --git a/internal/brokerapi/local_api_session_projection_link_helpers.go b/internal/brokerapi/local_api_session_projection_link_helpers.go new file mode 100644 index 00000000..59a411fd --- /dev/null +++ b/internal/brokerapi/local_api_session_projection_link_helpers.go @@ -0,0 +1,67 @@ +package brokerapi + +import ( + "strings" + + "github.com/runecode-ai/runecode/internal/artifacts" +) + +func sessionDetailLinkedRunIndex(base map[string]struct{}, executions []artifacts.SessionTurnExecutionDurableState) map[string]struct{} { + out := copyLinkIndex(base) + for _, execution := range executions { + appendLinkIndexValue(out, execution.PrimaryRunID) + appendLinkIndexValues(out, execution.LinkedRunIDs) + } + return out +} + +func sessionDetailLinkedApprovalIndex(base map[string]struct{}, executions []artifacts.SessionTurnExecutionDurableState) map[string]struct{} { + out := copyLinkIndex(base) + for _, execution := range executions { + appendLinkIndexValue(out, execution.PendingApprovalID) + appendLinkIndexValues(out, execution.LinkedApprovalIDs) + } + return out +} + +func sessionDetailLinkedArtifactIndex(base map[string]struct{}, executions []artifacts.SessionTurnExecutionDurableState) map[string]struct{} { + return mergeExecutionLinkDigests(base, executions, func(execution artifacts.SessionTurnExecutionDurableState) []string { + return execution.LinkedArtifactDigests + }) +} + +func sessionDetailLinkedAuditIndex(base map[string]struct{}, executions []artifacts.SessionTurnExecutionDurableState) map[string]struct{} { + return mergeExecutionLinkDigests(base, executions, func(execution artifacts.SessionTurnExecutionDurableState) []string { + return execution.LinkedAuditRecordDigests + }) +} + +func mergeExecutionLinkDigests(base map[string]struct{}, executions []artifacts.SessionTurnExecutionDurableState, selector func(artifacts.SessionTurnExecutionDurableState) []string) map[string]struct{} { + out := copyLinkIndex(base) + for _, execution := range executions { + appendLinkIndexValues(out, selector(execution)) + } + return out +} + +func copyLinkIndex(in map[string]struct{}) map[string]struct{} { + out := map[string]struct{}{} + for value := range in { + out[value] = struct{}{} + } + return out +} + +func appendLinkIndexValues(index map[string]struct{}, values []string) { + for _, value := range values { + appendLinkIndexValue(index, value) + } +} + +func appendLinkIndexValue(index map[string]struct{}, value string) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return + } + index[trimmed] = struct{}{} +} diff --git a/internal/brokerapi/local_rpc_client_linux_test.go b/internal/brokerapi/local_rpc_client_linux_test.go index f5c9a446..90f86a77 100644 --- a/internal/brokerapi/local_rpc_client_linux_test.go +++ b/internal/brokerapi/local_rpc_client_linux_test.go @@ -148,7 +148,7 @@ func assertLocalRPCSocketPath(t *testing.T, runtimeDir string) { func setupLocalRPCRunListRoundTrip(t *testing.T, service *Service) (string, *LocalRPCClient, chan error) { t.Helper() - runtimeDir := filepath.Join(t.TempDir(), "runtime") + runtimeDir := shortLocalRPCRuntimeDir(t) listener, err := ListenLocalIPC(LocalIPCConfig{RuntimeDir: runtimeDir, SocketName: "broker.sock"}) if err != nil { t.Fatalf("ListenLocalIPC returned error: %v", err) @@ -172,7 +172,7 @@ func setupLocalRPCRunListRoundTrip(t *testing.T, service *Service) (string, *Loc func setupLocalRPCSessionListRoundTrip(t *testing.T, service *Service) (string, *LocalRPCClient, chan error) { t.Helper() - runtimeDir := filepath.Join(t.TempDir(), "runtime") + runtimeDir := shortLocalRPCRuntimeDir(t) listener, err := ListenLocalIPC(LocalIPCConfig{RuntimeDir: runtimeDir, SocketName: "broker.sock"}) if err != nil { t.Fatalf("ListenLocalIPC returned error: %v", err) @@ -196,7 +196,7 @@ func setupLocalRPCSessionListRoundTrip(t *testing.T, service *Service) (string, func setupLocalRPCSessionSendMessageRoundTrip(t *testing.T, service *Service) (string, *LocalRPCClient, chan error) { t.Helper() - runtimeDir := filepath.Join(t.TempDir(), "runtime") + runtimeDir := shortLocalRPCRuntimeDir(t) listener, err := ListenLocalIPC(LocalIPCConfig{RuntimeDir: runtimeDir, SocketName: "broker.sock"}) if err != nil { t.Fatalf("ListenLocalIPC returned error: %v", err) @@ -220,7 +220,7 @@ func setupLocalRPCSessionSendMessageRoundTrip(t *testing.T, service *Service) (s func setupLocalRPCSessionExecutionTriggerRoundTrip(t *testing.T, service *Service) (string, *LocalRPCClient, chan error) { t.Helper() - runtimeDir := filepath.Join(t.TempDir(), "runtime") + runtimeDir := shortLocalRPCRuntimeDir(t) listener, err := ListenLocalIPC(LocalIPCConfig{RuntimeDir: runtimeDir, SocketName: "broker.sock"}) if err != nil { t.Fatalf("ListenLocalIPC returned error: %v", err) @@ -427,7 +427,7 @@ func TestValidateRawMessageLimitsRejectsLargePayload(t *testing.T) { } func TestLocalRPCClientInvokeRespectsContextDeadline(t *testing.T) { - runtimeDir := filepath.Join(t.TempDir(), "runtime") + runtimeDir := shortLocalRPCRuntimeDir(t) listener, err := ListenLocalIPC(LocalIPCConfig{RuntimeDir: runtimeDir, SocketName: "broker.sock"}) if err != nil { t.Fatalf("ListenLocalIPC returned error: %v", err) @@ -457,3 +457,13 @@ func TestLocalRPCClientInvokeRespectsContextDeadline(t *testing.T) { t.Fatalf("error code = %q, want request_cancelled", errResp.Error.Code) } } + +func shortLocalRPCRuntimeDir(t *testing.T) string { + t.Helper() + runtimeDir, err := os.MkdirTemp("", "rc-rpc-") + if err != nil { + t.Fatalf("MkdirTemp returned error: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(runtimeDir) }) + return runtimeDir +} diff --git a/internal/brokerapi/perf_gateway_secrets.go b/internal/brokerapi/perf_gateway_secrets.go new file mode 100644 index 00000000..c399c026 --- /dev/null +++ b/internal/brokerapi/perf_gateway_secrets.go @@ -0,0 +1,155 @@ +package brokerapi + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/runecode-ai/runecode/internal/perfcontracts" + "github.com/runecode-ai/runecode/internal/trustpolicy" +) + +func measurePhase5GatewayAndSecrets(trials int, repoRoot string) ([]perfcontracts.MeasurementRecord, error) { + rig, err := newPhase5GatewayPerfRig(repoRoot) + if err != nil { + return nil, err + } + defer rig.cleanup() + invokeP95, err := phase5TrialP95(trials, rig.invokeGatewayTrial) + if err != nil { + return nil, err + } + leaseP95, err := phase5TrialP95(trials, rig.issueLeaseTrial) + if err != nil { + return nil, err + } + ingressP95, err := phase5TrialP95(trials, rig.ingressPrepareSubmitTrial) + if err != nil { + return nil, err + } + return []perfcontracts.MeasurementRecord{ + {MetricID: "metric.gateway.model_invoke.overhead.p95_ms", Value: invokeP95, Unit: "ms"}, + {MetricID: "metric.secrets.lease_issue.p95_ms", Value: leaseP95, Unit: "ms"}, + {MetricID: "metric.secrets.ingress.prepare_submit.p95_ms", Value: ingressP95, Unit: "ms"}, + }, nil +} + +func phase5MeasureMS(call func()) float64 { + started := time.Now() + call() + return float64(time.Since(started).Microseconds()) / 1000.0 +} + +func phase5MeasureMSErr(call func() error) (float64, error) { + started := time.Now() + if err := call(); err != nil { + return 0, err + } + return float64(time.Since(started).Microseconds()) / 1000.0, nil +} + +func phase5TrialP95(trials int, trial func(int) error) (float64, error) { + samples := make([]float64, 0, trials) + for i := 0; i < trials; i++ { + ms, err := phase5MeasureMSErr(func() error { return trial(i) }) + if err != nil { + return 0, err + } + samples = append(samples, ms) + } + return phase5P95(samples) +} + +type phase5GatewayPerfRig struct { + service *Service + runID string + providerProfileID string + llmRequest map[string]any + requestDigest trustpolicy.Digest + cleanupFn func() +} + +func newPhase5GatewayPerfRig(repoRoot string) (*phase5GatewayPerfRig, error) { + service, cleanup, err := newPhase5GatewayPerfService(repoRoot) + if err != nil { + return nil, err + } + rig := &phase5GatewayPerfRig{service: service, runID: "run-phase5-gateway", cleanupFn: cleanup} + if err := putPhase5TrustedModelGatewayContext(service, rig.runID); err != nil { + rig.cleanup() + return nil, err + } + if err := rig.seedProviderAndLLMRequest(); err != nil { + rig.cleanup() + return nil, err + } + return rig, nil +} + +func (r *phase5GatewayPerfRig) cleanup() { + if r != nil && r.cleanupFn != nil { + r.cleanupFn() + } +} + +func (r *phase5GatewayPerfRig) invokeGatewayTrial(iteration int) error { + req := LLMInvokeRequest{ + SchemaID: "runecode.protocol.v0.LLMInvokeRequest", + SchemaVersion: "0.1.0", + RequestID: fmt.Sprintf("req-phase5-llm-invoke-%d", iteration), + RunID: r.runID, + LLMRequest: r.llmRequest, + RequestDigest: &r.requestDigest, + } + _, errResp := r.service.HandleLLMInvoke(context.Background(), req, RequestContext{}) + if errResp == nil { + return nil + } + return fmt.Errorf("llm invoke: %s (%s)", errResp.Error.Code, strings.TrimSpace(errResp.Error.Message)) +} + +func (r *phase5GatewayPerfRig) issueLeaseTrial(iteration int) error { + req := ProviderCredentialLeaseIssueRequest{ + SchemaID: "runecode.protocol.v0.ProviderCredentialLeaseIssueRequest", + SchemaVersion: "0.1.0", + RequestID: fmt.Sprintf("req-phase5-lease-%d", iteration), + ProviderProfileID: r.providerProfileID, + RunID: r.runID, + TTLSeconds: 120, + } + _, errResp := r.service.HandleProviderCredentialLeaseIssue(context.Background(), req, RequestContext{}) + return phase5DependencyErr("provider lease issue", errResp) +} + +func (r *phase5GatewayPerfRig) ingressPrepareSubmitTrial(iteration int) error { + beginResp, err := r.beginProviderSetupSession(fmt.Sprintf("req-phase5-ingress-begin-%d", iteration), fmt.Sprintf("phase5-ingress-%d.example.com", iteration)) + if err != nil { + return err + } + return r.submitIngressForSession(beginResp.SetupSession.SetupSessionID, fmt.Sprintf("%d", iteration)) +} + +func (r *phase5GatewayPerfRig) submitIngressForSession(setupSessionID string, suffix string) error { + prepareResp, prepareErr := r.service.HandleProviderSetupSecretIngressPrepare(context.Background(), ProviderSetupSecretIngressPrepareRequest{ + SchemaID: "runecode.protocol.v0.ProviderSetupSecretIngressPrepareRequest", + SchemaVersion: "0.1.0", + RequestID: "req-phase5-ingress-prepare-" + suffix, + SetupSessionID: setupSessionID, + IngressChannel: "cli_stdin", + CredentialField: "api_key", + }, RequestContext{}) + if err := phase5DependencyErr("provider ingress prepare", prepareErr); err != nil { + return err + } + _, submitErr := r.service.HandleProviderSetupSecretIngressSubmit(context.Background(), ProviderSetupSecretIngressSubmitRequest{ + SchemaID: "runecode.protocol.v0.ProviderSetupSecretIngressSubmitRequest", + SchemaVersion: "0.1.0", + RequestID: "req-phase5-ingress-submit-" + suffix, + SecretIngressToken: prepareResp.SecretIngressToken, + }, []byte("phase5-secret"), RequestContext{}) + if err := phase5DependencyErr("provider ingress submit", submitErr); err != nil { + return err + } + return nil +} diff --git a/internal/brokerapi/perf_gateway_secrets_context.go b/internal/brokerapi/perf_gateway_secrets_context.go new file mode 100644 index 00000000..2202eb50 --- /dev/null +++ b/internal/brokerapi/perf_gateway_secrets_context.go @@ -0,0 +1,220 @@ +package brokerapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "strings" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/perffixtures" + "github.com/runecode-ai/runecode/third_party/jsoncanonicalizer" +) + +func (r *phase5GatewayPerfRig) seedProviderAndLLMRequest() error { + beginResp, err := r.beginProviderSetupSession("req-phase5-provider-begin", "model.example.com") + if err != nil { + return err + } + r.providerProfileID = beginResp.Profile.ProviderProfileID + r.llmRequest = phase5GatewayLLMRequest(r.providerProfileID) + if err := r.submitIngressForSession(beginResp.SetupSession.SetupSessionID, "seed"); err != nil { + return err + } + if err := r.commitProviderValidation(); err != nil { + return err + } + if err := r.putGatewayPromptArtifact(); err != nil { + return err + } + return r.putLLMRequestArtifact() +} + +func (r *phase5GatewayPerfRig) beginProviderSetupSession(requestID, canonicalHost string) (ProviderSetupSessionBeginResponse, error) { + beginResp, errResp := r.service.HandleProviderSetupSessionBegin(context.Background(), ProviderSetupSessionBeginRequest{ + SchemaID: "runecode.protocol.v0.ProviderSetupSessionBeginRequest", + SchemaVersion: "0.1.0", + RequestID: requestID, + DisplayLabel: "Phase5 Gateway", + ProviderFamily: providerFamilyOpenAICompatible, + AdapterKind: providerAdapterKindOpenAIChatCompletionsV0, + CanonicalHost: canonicalHost, + CanonicalPathPrefix: "/v1", + AllowlistedModelIDs: []string{"gpt-4.1-mini"}, + }, RequestContext{}) + if err := phase5DependencyErr("provider setup begin", errResp); err != nil { + return ProviderSetupSessionBeginResponse{}, err + } + return beginResp, nil +} + +func (r *phase5GatewayPerfRig) commitProviderValidation() error { + validationBegin, validationErr := r.service.HandleProviderValidationBegin(context.Background(), ProviderValidationBeginRequest{ + SchemaID: "runecode.protocol.v0.ProviderValidationBeginRequest", + SchemaVersion: "0.1.0", + RequestID: "req-phase5-provider-validation-begin", + ProviderProfileID: r.providerProfileID, + }, RequestContext{}) + if err := phase5DependencyErr("provider validation begin", validationErr); err != nil { + return err + } + _, validationCommitErr := r.service.HandleProviderValidationCommit(context.Background(), ProviderValidationCommitRequest{ + SchemaID: "runecode.protocol.v0.ProviderValidationCommitRequest", + SchemaVersion: "0.1.0", + RequestID: "req-phase5-provider-validation-commit", + ProviderProfileID: r.providerProfileID, + ValidationAttemptID: validationBegin.ValidationAttemptID, + ConnectivityState: "reachable", + CompatibilityState: "compatible", + }, RequestContext{}) + return phase5DependencyErr("provider validation commit", validationCommitErr) +} + +func (r *phase5GatewayPerfRig) putGatewayPromptArtifact() error { + inputRef, err := r.service.Put(artifacts.PutRequest{ + Payload: []byte("phase5 gateway prompt"), + ContentType: "text/plain", + DataClass: artifacts.DataClassSpecText, + ProvenanceReceiptHash: "sha256:" + strings.Repeat("c", 64), + CreatedByRole: "broker", + TrustedSource: true, + RunID: r.runID, + StepID: "phase5-gateway-input", + }) + if err != nil { + return err + } + requestArtifacts, ok := r.llmRequest["input_artifacts"].([]any) + if !ok || len(requestArtifacts) == 0 { + return fmt.Errorf("phase5 gateway request missing input_artifacts") + } + artifact, ok := requestArtifacts[0].(map[string]any) + if !ok { + return fmt.Errorf("phase5 gateway request input_artifact malformed") + } + artifact["digest"] = phase5DigestObject(inputRef.Digest) + artifact["size_bytes"] = len("phase5 gateway prompt") + return nil +} + +func phase5GatewayLLMRequest(providerProfileID string) map[string]any { + return map[string]any{ + "schema_id": "runecode.protocol.v0.LLMRequest", + "schema_version": "0.3.0", + "selection_source": "signed_allowlist", + "provider": providerProfileID, + "model": "gpt-4.1-mini", + "input_artifacts": []any{map[string]any{ + "schema_id": "runecode.protocol.v0.ArtifactReference", + "schema_version": "0.4.0", + "digest": map[string]any{}, + "size_bytes": 0, + "content_type": "text/plain", + "data_class": "spec_text", + "provenance_receipt_hash": map[string]any{ + "hash_alg": "sha256", + "hash": strings.Repeat("d", 64), + }, + }}, + "tool_allowlist": []any{map[string]any{ + "tool_name": "noop", + "arguments_schema_id": "runecode.protocol.tools.noop.args", + "arguments_schema_version": "0.1.0", + }}, + "response_mode": "text", + "streaming_mode": "stream", + "request_limits": map[string]any{ + "max_request_bytes": 262144, + "max_tool_calls": 8, + "max_total_tool_call_argument_bytes": 65536, + "max_structured_output_bytes": 262144, + "max_streamed_bytes": 16777216, + "max_stream_chunk_bytes": 65536, + "stream_idle_timeout_ms": 15000, + }, + } +} + +func (r *phase5GatewayPerfRig) putLLMRequestArtifact() error { + raw, err := json.Marshal(r.llmRequest) + if err != nil { + return err + } + canonical, err := jsoncanonicalizer.Transform(raw) + if err != nil { + return err + } + if err := json.Unmarshal(canonical, &r.llmRequest); err != nil { + return err + } + digest, err := canonicalDigestForValue(r.llmRequest) + if err != nil { + return err + } + r.requestDigest = digest + _, err = r.service.Put(artifacts.PutRequest{ + Payload: canonical, + ContentType: "application/json", + DataClass: artifacts.DataClassSpecText, + ProvenanceReceiptHash: "sha256:" + strings.Repeat("a", 64), + CreatedByRole: "broker", + TrustedSource: true, + RunID: r.runID, + StepID: "phase5-gateway-request", + }) + return err +} + +func newPhase5GatewayPerfService(repoRoot string) (*Service, func(), error) { + storeRoot, err := os.MkdirTemp("", "runecode-phase5-gateway-store-") + if err != nil { + return nil, nil, err + } + ledgerRoot, err := os.MkdirTemp("", "runecode-phase5-gateway-ledger-") + if err != nil { + _ = os.RemoveAll(storeRoot) + return nil, nil, err + } + cleanup := func() { + _ = os.RemoveAll(storeRoot) + _ = os.RemoveAll(ledgerRoot) + } + service, err := NewServiceWithConfig(storeRoot, ledgerRoot, APIConfig{RepositoryRoot: repoRoot}) + if err != nil { + cleanup() + return nil, nil, err + } + service.gatewayRuntime.resolver = phase5GatewayStaticResolver{} + originalClient := newLLMHTTPClient + newLLMHTTPClient = func() llmHTTPClient { + return &http.Client{Transport: phase5StubProviderTransport{backend: perffixtures.StubProviderBackend{}}} + } + return service, func() { + newLLMHTTPClient = originalClient + cleanup() + }, nil +} + +type phase5GatewayStaticResolver struct{} + +func (phase5GatewayStaticResolver) LookupIP(_ context.Context, _ string, _ string) ([]net.IP, error) { + return []net.IP{net.ParseIP("93.184.216.34")}, nil +} + +type phase5StubProviderTransport struct { + backend perffixtures.StubProviderBackend +} + +func (t phase5StubProviderTransport) RoundTrip(req *http.Request) (*http.Response, error) { + response := t.backend.Invoke(req.Context(), perffixtures.StubProviderRequest{Prompt: "phase5"}) + body, err := json.Marshal(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": response.Text}}}}) + if err != nil { + return nil, err + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(bytes.NewReader(body)), Request: req}, nil +} diff --git a/internal/brokerapi/perf_gateway_secrets_policy.go b/internal/brokerapi/perf_gateway_secrets_policy.go new file mode 100644 index 00000000..c78d9f5d --- /dev/null +++ b/internal/brokerapi/perf_gateway_secrets_policy.go @@ -0,0 +1,100 @@ +package brokerapi + +import ( + "encoding/json" + + "github.com/runecode-ai/runecode/internal/artifacts" +) + +func putPhase5TrustedModelGatewayContext(service *Service, runID string) error { + verifier, privateKey, err := phase5VerifierFixture() + if err != nil { + return err + } + if err := phase5PutTrustedVerifierRecord(service, verifier); err != nil { + return err + } + allowlistPayload, err := json.Marshal(phase5GatewayAllowlistPayload()) + if err != nil { + return err + } + allowlistDigest, err := phase5PutTrustedPolicyArtifact(service, runID, artifacts.TrustedContractImportKindPolicyAllowlist, allowlistPayload) + if err != nil { + return err + } + rolePayload, err := phase5SignedPayloadForTrustedContext(phase5GatewayRoleManifest(runID, allowlistDigest), verifier, privateKey) + if err != nil { + return err + } + runPayload, err := phase5SignedPayloadForTrustedContext(phase5GatewayRunCapability(runID, allowlistDigest), verifier, privateKey) + if err != nil { + return err + } + if _, err := phase5PutTrustedPolicyArtifact(service, runID, artifacts.TrustedContractImportKindRoleManifest, rolePayload); err != nil { + return err + } + if _, err := phase5PutTrustedPolicyArtifact(service, runID, artifacts.TrustedContractImportKindRunCapability, runPayload); err != nil { + return err + } + return nil +} + +func phase5GatewayAllowlistPayload() map[string]any { + return map[string]any{ + "schema_id": "runecode.protocol.v0.PolicyAllowlist", + "schema_version": "0.1.0", + "allowlist_kind": "gateway_scope_rule", + "entry_schema_id": "runecode.protocol.v0.GatewayScopeRule", + "entries": []any{map[string]any{ + "schema_id": "runecode.protocol.v0.GatewayScopeRule", + "schema_version": "0.1.0", + "scope_kind": "gateway_destination", + "entry_id": "model_default", + "gateway_role_kind": "model-gateway", + "destination": phase5GatewayDestinationDescriptor(), + "permitted_operations": []any{"invoke_model"}, + "allowed_egress_data_classes": []any{"spec_text"}, + "redirect_posture": "allowlist_only", + "max_timeout_seconds": 120, + "max_response_bytes": 16 << 20, + }}, + } +} + +func phase5GatewayDestinationDescriptor() map[string]any { + return map[string]any{ + "schema_id": "runecode.protocol.v0.DestinationDescriptor", + "schema_version": "0.1.0", + "descriptor_kind": "model_endpoint", + "canonical_host": "model.example.com", + "tls_required": true, + "private_range_blocking": "enforced", + "dns_rebinding_protection": "enforced", + } +} + +func phase5GatewayRoleManifest(runID string, allowlistDigest string) map[string]any { + return map[string]any{ + "schema_id": "runecode.protocol.v0.RoleManifest", + "schema_version": "0.2.0", + "principal": phase5SignedContextPrincipal(runID, "gateway", "model-gateway"), + "role_family": "gateway", + "role_kind": "model-gateway", + "approval_profile": "moderate", + "capability_opt_ins": []any{"cap_gateway"}, + "allowlist_refs": []any{phase5DigestObject(allowlistDigest)}, + } +} + +func phase5GatewayRunCapability(runID string, allowlistDigest string) map[string]any { + return map[string]any{ + "schema_id": "runecode.protocol.v0.CapabilityManifest", + "schema_version": "0.2.0", + "principal": phase5SignedContextPrincipal(runID, "gateway", "model-gateway"), + "manifest_scope": "run", + "run_id": runID, + "approval_profile": "moderate", + "capability_opt_ins": []any{"cap_gateway"}, + "allowlist_refs": []any{phase5DigestObject(allowlistDigest)}, + } +} diff --git a/internal/brokerapi/perf_phase5_anchor.go b/internal/brokerapi/perf_phase5_anchor.go new file mode 100644 index 00000000..1ed2ec7d --- /dev/null +++ b/internal/brokerapi/perf_phase5_anchor.go @@ -0,0 +1,121 @@ +package brokerapi + +import ( + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/runecode-ai/runecode/internal/perfcontracts" +) + +func measurePhase5ExternalAnchorStubbed(trials int) []perfcontracts.MeasurementRecord { + target := newPhase5ExternalAnchorStub() + prepareSamples, deferredSamples, completedSamples, visibilitySamples, receiptSamples := phase5AnchorSamples(trials, target) + prepareP95, _ := phase5P95(prepareSamples) + deferredP95, _ := phase5P95(deferredSamples) + completedP95, _ := phase5P95(completedSamples) + visibilityP95, _ := phase5P95(visibilitySamples) + receiptP95, _ := phase5P95(receiptSamples) + return []perfcontracts.MeasurementRecord{ + {MetricID: "metric.anchor.prepare.latency.p95_ms", Value: prepareP95, Unit: "ms"}, + {MetricID: "metric.anchor.execute.completed.p95_ms", Value: completedP95, Unit: "ms"}, + {MetricID: "metric.anchor.execute.deferred.handoff.p95_ms", Value: deferredP95, Unit: "ms"}, + {MetricID: "metric.anchor.deferred.visibility.p95_ms", Value: visibilityP95, Unit: "ms"}, + {MetricID: "metric.anchor.receipt_admission.unchanged_seal.p95_ms", Value: receiptP95, Unit: "ms"}, + {MetricID: "metric.anchor.network_io_under_ledger_lock.count", Value: float64(target.networkUnderLock.Load()), Unit: "count"}, + {MetricID: "metric.anchor.verifier_bypass.count", Value: float64(target.verifierBypass.Load()), Unit: "count"}, + } +} + +func phase5AnchorSamples(trials int, target *phase5ExternalAnchorStub) ([]float64, []float64, []float64, []float64, []float64) { + prepareSamples := make([]float64, 0, trials) + deferredSamples := make([]float64, 0, trials) + completedSamples := make([]float64, 0, trials) + visibilitySamples := make([]float64, 0, trials) + receiptSamples := make([]float64, 0, trials) + for i := 0; i < trials; i++ { + prepareMS, completedMS, deferredMS, visibilityMS, receiptMS := phase5AnchorTrial(i, target) + prepareSamples = append(prepareSamples, prepareMS) + completedSamples = append(completedSamples, completedMS) + deferredSamples = append(deferredSamples, deferredMS) + visibilitySamples = append(visibilitySamples, visibilityMS) + receiptSamples = append(receiptSamples, receiptMS) + } + return prepareSamples, deferredSamples, completedSamples, visibilitySamples, receiptSamples +} + +func phase5AnchorTrial(i int, target *phase5ExternalAnchorStub) (float64, float64, float64, float64, float64) { + seal := fmt.Sprintf("sha256:%064d", i+1) + prepareMS := phase5MeasureMS(func() { target.Prepare(seal) }) + completedMS := phase5MeasureMS(func() { target.ExecuteFastComplete(seal) }) + deferredSeal := fmt.Sprintf("sha256:%064d", i+100) + target.Prepare(deferredSeal) + var requestID string + deferredMS := phase5MeasureMS(func() { requestID = target.ExecuteDeferred(deferredSeal) }) + visibilityMS := phase5MeasureMS(func() { _ = target.WaitCompleted(requestID, 2*time.Second) }) + receiptMS := phase5MeasureMS(func() { target.AdmitReceiptUnchangedSeal(seal) }) + return prepareMS, completedMS, deferredMS, visibilityMS, receiptMS +} + +type phase5ExternalAnchorStub struct { + mu sync.Mutex + statusByRequest map[string]string + sealVerified map[string]struct{} + nextID int + networkUnderLock atomic.Int64 + verifierBypass atomic.Int64 +} + +func newPhase5ExternalAnchorStub() *phase5ExternalAnchorStub { + return &phase5ExternalAnchorStub{statusByRequest: map[string]string{}, sealVerified: map[string]struct{}{}} +} + +func (s *phase5ExternalAnchorStub) Prepare(sealDigest string) { + s.mu.Lock() + defer s.mu.Unlock() + s.sealVerified[sealDigest] = struct{}{} +} + +func (s *phase5ExternalAnchorStub) ExecuteFastComplete(_ string) { time.Sleep(1 * time.Millisecond) } + +func (s *phase5ExternalAnchorStub) ExecuteDeferred(_ string) string { + s.mu.Lock() + s.nextID++ + requestID := fmt.Sprintf("deferred-%d", s.nextID) + s.statusByRequest[requestID] = "deferred" + s.mu.Unlock() + go func(id string) { + time.Sleep(5 * time.Millisecond) + s.mu.Lock() + s.statusByRequest[id] = "completed" + s.mu.Unlock() + }(requestID) + return requestID +} + +func (s *phase5ExternalAnchorStub) WaitCompleted(requestID string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for { + s.mu.Lock() + status := s.statusByRequest[requestID] + s.mu.Unlock() + if status == "completed" { + return true + } + if time.Now().After(deadline) { + return false + } + time.Sleep(1 * time.Millisecond) + } +} + +func (s *phase5ExternalAnchorStub) AdmitReceiptUnchangedSeal(sealDigest string) { + s.mu.Lock() + _, ok := s.sealVerified[sealDigest] + s.mu.Unlock() + if !ok { + s.verifierBypass.Add(1) + } + time.Sleep(1 * time.Millisecond) +} diff --git a/internal/brokerapi/perf_phase5_checks.go b/internal/brokerapi/perf_phase5_checks.go new file mode 100644 index 00000000..937a1be6 --- /dev/null +++ b/internal/brokerapi/perf_phase5_checks.go @@ -0,0 +1,147 @@ +package brokerapi + +import ( + "context" + "fmt" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/runecode-ai/runecode/internal/perfcontracts" +) + +func measurePhase5AuditVerification( + trials int, + repoRoot string, + timeout time.Duration, + runner func(repoRoot string, timeout time.Duration, command ...string) (float64, error), +) ([]perfcontracts.MeasurementRecord, error) { + verifyRun := func() (float64, error) { + return runner(repoRoot, timeout, "go", "test", "./internal/auditd", "-run", "TestVerifyCurrentSegmentIncrementalWithPreverifiedSealPersistsReport", "-count=1") + } + verifyMS, err := phase5WarmupThenMedianCommandLatency(trials, verifyRun) + if err != nil { + return nil, fmt.Errorf("audit verify fixture check failed: %w", err) + } + finalizeRun := func() (float64, error) { + return runner(repoRoot, timeout, "go", "test", "./internal/brokerapi", "-run", "TestHandleAuditFinalizeVerifyPersistsVerificationReportForCurrentSeal", "-count=1") + } + finalizeMS, err := phase5WarmupThenMedianCommandLatency(trials, finalizeRun) + if err != nil { + return nil, fmt.Errorf("audit finalize verify fixture check failed: %w", err) + } + return []perfcontracts.MeasurementRecord{ + {MetricID: "metric.audit.verify_current_segment.wall_ms", Value: verifyMS, Unit: "ms"}, + {MetricID: "metric.audit.finalize_verify.wall_ms", Value: finalizeMS, Unit: "ms"}, + }, nil +} + +func measurePhase5ProtocolChecks( + repoRoot string, + timeout time.Duration, + runner func(repoRoot string, timeout time.Duration, command ...string) (float64, error), +) ([]perfcontracts.MeasurementRecord, error) { + schemaMS, err := runner(repoRoot, timeout, "go", "test", "./internal/protocolschema") + if err != nil { + return nil, fmt.Errorf("protocol schema validation check failed: %w", err) + } + fixtureMS, err := runner(repoRoot, timeout, "node", "--test", "scripts/protocol-fixtures.test.js") + if err != nil { + return nil, fmt.Errorf("protocol fixture parity check failed: %w", err) + } + return []perfcontracts.MeasurementRecord{ + {MetricID: "metric.protocol.schema_validation.wall_ms", Value: schemaMS, Unit: "ms"}, + {MetricID: "metric.protocol.fixture_parity.wall_ms", Value: fixtureMS, Unit: "ms"}, + }, nil +} + +func phase5RunCommand(repoRoot string, timeout time.Duration, command ...string) (float64, error) { + if len(command) == 0 { + return 0, fmt.Errorf("command required") + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, command[0], command[1:]...) + cmd.Dir = phase5CommandDir(repoRoot, command[0]) + start := time.Now() + output, err := cmd.CombinedOutput() + if err != nil { + return 0, phase5CommandError(command, output, err) + } + return float64(time.Since(start).Microseconds()) / 1000.0, nil +} + +func phase5CommandDir(repoRoot, bin string) string { + if bin == "node" { + return filepath.Join(repoRoot, "runner") + } + return repoRoot +} + +func phase5CommandError(command []string, output []byte, runErr error) error { + msg := strings.TrimSpace(string(output)) + if msg == "" { + msg = runErr.Error() + } + return fmt.Errorf("%s failed: %s", strings.Join(command, " "), msg) +} + +func phase5WarmupThenMedianCommandLatency(trials int, run func() (float64, error)) (float64, error) { + if _, err := run(); err != nil { + return 0, err + } + return phase5MedianCommandLatency(trials, run) +} + +func phase5MedianCommandLatency(trials int, run func() (float64, error)) (float64, error) { + if trials <= 0 { + trials = 1 + } + samples := make([]float64, 0, trials) + for i := 0; i < trials; i++ { + value, err := run() + if err != nil { + return 0, err + } + samples = append(samples, value) + } + return phase5Median(samples) +} + +func phase5P95(values []float64) (float64, error) { + if len(values) == 0 { + return 0, fmt.Errorf("samples required") + } + cp := append([]float64(nil), values...) + sort.Float64s(cp) + idx := int(float64(len(cp)-1) * 0.95) + if idx < 0 { + idx = 0 + } + if idx >= len(cp) { + idx = len(cp) - 1 + } + return cp[idx], nil +} + +func phase5Median(values []float64) (float64, error) { + if len(values) == 0 { + return 0, fmt.Errorf("samples required") + } + cp := append([]float64(nil), values...) + sort.Float64s(cp) + mid := len(cp) / 2 + if len(cp)%2 == 0 { + return (cp[mid-1] + cp[mid]) / 2, nil + } + return cp[mid], nil +} + +func boolToCount(v bool) float64 { + if v { + return 1 + } + return 0 +} diff --git a/internal/brokerapi/perf_phase5_dependency.go b/internal/brokerapi/perf_phase5_dependency.go new file mode 100644 index 00000000..bf2eff14 --- /dev/null +++ b/internal/brokerapi/perf_phase5_dependency.go @@ -0,0 +1,214 @@ +package brokerapi + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/runecode-ai/runecode/internal/perfcontracts" +) + +func measurePhase5DependencyFlow(trials int, repoRoot string) ([]perfcontracts.MeasurementRecord, error) { + service, cleanup, err := newPhase5DependencyService(repoRoot) + if err != nil { + return nil, err + } + defer cleanup() + if err := putPhase5TrustedDependencyContext(service, "run-deps-phase5"); err != nil { + return nil, err + } + coreMetrics, err := measurePhase5DependencyCore(service) + if err != nil { + return nil, err + } + extraMetrics, err := measurePhase5DependencyExtras(service) + if err != nil { + return nil, err + } + return append(coreMetrics, extraMetrics...), nil +} + +func measurePhase5DependencyCore(service *Service) ([]perfcontracts.MeasurementRecord, error) { + missMS, hitMS, err := measurePhase5DependencyMissAndHit(service) + if err != nil { + return nil, err + } + coalesced, err := measurePhase5DependencyCoalescing(service) + if err != nil { + return nil, err + } + base := []perfcontracts.MeasurementRecord{ + phase5DependencyMetric("metric.deps.cache_miss.small.wall_ms", missMS, "ms"), + phase5DependencyMetric("metric.deps.cache_hit.small.wall_ms", hitMS, "ms"), + } + return append(base, coalesced...), nil +} + +func measurePhase5DependencyExtras(service *Service) ([]perfcontracts.MeasurementRecord, error) { + streaming, err := measurePhase5DependencyStreaming(service) + if err != nil { + return nil, err + } + handoff, err := measurePhase5DependencyHandoff(service) + if err != nil { + return nil, err + } + return append(streaming, handoff...), nil +} + +func measurePhase5DependencyMissAndHit(service *Service) (float64, float64, error) { + missHitFetcher := &phase5CountingFetcher{payload: "phase5-dependency-payload"} + service.SetDependencyRegistryFetcherForTests(missHitFetcher) + missReq := phase5DependencyFetchRegistryRequest("req-deps-miss", "run-deps-phase5", "alpha") + missMS, missResp, err := phase5TimedDependencyFetch(service, missReq, "dependency miss fetch") + if err != nil { + return 0, 0, err + } + hitReq := missReq + hitReq.RequestID = "req-deps-hit" + hitMS, hitResp, err := phase5TimedDependencyFetch(service, hitReq, "dependency hit fetch") + if err != nil { + return 0, 0, err + } + if missResp.CacheOutcome != "miss_filled" || hitResp.CacheOutcome != "hit_exact" { + return 0, 0, fmt.Errorf("unexpected dependency cache outcomes miss=%q hit=%q", missResp.CacheOutcome, hitResp.CacheOutcome) + } + return missMS, hitMS, nil +} + +func phase5TimedDependencyFetch(service *Service, req DependencyFetchRegistryRequest, action string) (float64, DependencyFetchRegistryResponse, error) { + started := time.Now() + resp, errResp := service.HandleDependencyFetchRegistry(context.Background(), req, RequestContext{}) + if err := phase5DependencyErr(action, errResp); err != nil { + return 0, DependencyFetchRegistryResponse{}, err + } + return float64(time.Since(started).Microseconds()) / 1000.0, resp, nil +} + +func measurePhase5DependencyCoalescing(service *Service) ([]perfcontracts.MeasurementRecord, error) { + coalesceFetcher := &phase5GatedFetcher{gate: make(chan struct{}), started: make(chan struct{})} + service.SetDependencyRegistryFetcherForTests(coalesceFetcher) + coalesceReq := phase5DependencyFetchRegistryRequest("req-deps-coalesce", "run-deps-phase5", "coalesced") + responses, wg := phase5StartCoalescedFetches(service, coalesceReq, 6) + if err := phase5WaitForCoalescedStart(coalesceFetcher); err != nil { + return nil, err + } + close(coalesceFetcher.gate) + wg.Wait() + if err := phase5ValidateCoalescedResponses(responses); err != nil { + return nil, err + } + calls := float64(coalesceFetcher.calls.Load()) + casWriteCount := 0.0 + if calls > 0 { + casWriteCount = 1 + } + return []perfcontracts.MeasurementRecord{ + phase5DependencyMetric("metric.deps.cache_coalesced.upstream_fetch_count", calls, "count"), + phase5DependencyMetric("metric.deps.cache_coalesced.cas_write_count", casWriteCount, "count"), + }, nil +} + +func phase5StartCoalescedFetches(service *Service, req DependencyFetchRegistryRequest, n int) ([]*ErrorResponse, *sync.WaitGroup) { + responses := make([]*ErrorResponse, n) + start := make(chan struct{}) + wg := &sync.WaitGroup{} + for i := 0; i < n; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + <-start + one := req + one.RequestID = fmt.Sprintf("%s-%d", req.RequestID, idx) + _, errResp := service.HandleDependencyFetchRegistry(context.Background(), one, RequestContext{}) + responses[idx] = errResp + }(i) + } + close(start) + return responses, wg +} + +func phase5WaitForCoalescedStart(fetcher *phase5GatedFetcher) error { + select { + case <-fetcher.started: + return nil + case <-time.After(2 * time.Second): + return fmt.Errorf("timed out waiting for coalesced fetch start") + } +} + +func phase5ValidateCoalescedResponses(responses []*ErrorResponse) error { + for i := range responses { + if responses[i] != nil { + return fmt.Errorf("coalesced request %d failed: %s", i, responses[i].Error.Code) + } + } + return nil +} + +func measurePhase5DependencyStreaming(service *Service) ([]perfcontracts.MeasurementRecord, error) { + service.SetDependencyRegistryFetcherForTests(&phase5ChunkBoundFetcher{payloadSize: 3 << 20, maxReadBuf: 128 << 10}) + chunkFetcher, _ := service.dependencyFetchService.fetcher.(*phase5ChunkBoundFetcher) + streamReq := phase5DependencyFetchRegistryRequest("req-deps-stream", "run-deps-phase5", "stream") + var memBefore, memAfter runtimeMem + memBefore.capture() + streamResp, streamErr := service.HandleDependencyFetchRegistry(context.Background(), streamReq, RequestContext{}) + memAfter.capture() + if err := phase5DependencyErr("streaming dependency fetch failed", streamErr); err != nil { + return nil, err + } + peakAllocMB := memAfter.allocMB() - memBefore.allocMB() + if peakAllocMB < 0 { + peakAllocMB = 0 + } + maxReadBuffer, readCalls := phase5ChunkFetcherStats(chunkFetcher) + return []perfcontracts.MeasurementRecord{ + phase5DependencyMetric("metric.deps.stream_to_cas.max_read_buffer_bytes", maxReadBuffer, "bytes"), + phase5DependencyMetric("metric.deps.stream_to_cas.read_calls", readCalls, "count"), + phase5DependencyMetric("metric.deps.stream_to_cas.fetched_bytes", float64(streamResp.FetchedBytes), "bytes"), + phase5DependencyMetric("metric.deps.cache_fill.peak_alloc_mb", peakAllocMB, "mb"), + }, nil +} + +func phase5ChunkFetcherStats(fetcher *phase5ChunkBoundFetcher) (float64, float64) { + if fetcher == nil { + return 0, 0 + } + return float64(fetcher.maxSeenBuf.Load()), float64(fetcher.readCalls.Load()) +} + +func measurePhase5DependencyHandoff(service *Service) ([]perfcontracts.MeasurementRecord, error) { + ensureResp, err := phase5EnsureDependency(service) + if err != nil { + return nil, err + } + handoffMS, handoffResp, err := phase5TimedDependencyHandoff(service) + if err != nil { + return nil, err + } + return []perfcontracts.MeasurementRecord{ + phase5DependencyMetric("metric.deps.materialization.workspace_handoff.wall_ms", handoffMS, "ms"), + phase5DependencyMetric("metric.deps.materialization.workspace_handoff.found_count", boolToCount(handoffResp.Found), "count"), + phase5DependencyMetric("metric.deps.cache_ensure.registry_requests", float64(ensureResp.RegistryRequestCount), "count"), + }, nil +} + +func phase5EnsureDependency(service *Service) (DependencyCacheEnsureResponse, error) { + req := phase5DependencyEnsureRequest("req-deps-ensure", "run-deps-phase5", "handoff") + resp, errResp := service.HandleDependencyCacheEnsure(context.Background(), req, RequestContext{}) + if err := phase5DependencyErr("dependency ensure failed", errResp); err != nil { + return DependencyCacheEnsureResponse{}, err + } + return resp, nil +} + +func phase5TimedDependencyHandoff(service *Service) (float64, DependencyCacheHandoffResponse, error) { + req := phase5DependencyHandoffRequest("req-deps-handoff", "handoff", "workspace") + started := time.Now() + resp, errResp := service.HandleDependencyCacheHandoff(context.Background(), req, RequestContext{}) + if err := phase5DependencyErr("dependency handoff failed", errResp); err != nil { + return 0, DependencyCacheHandoffResponse{}, err + } + return float64(time.Since(started).Microseconds()) / 1000.0, resp, nil +} diff --git a/internal/brokerapi/perf_phase5_dependency_context.go b/internal/brokerapi/perf_phase5_dependency_context.go new file mode 100644 index 00000000..3a450dc6 --- /dev/null +++ b/internal/brokerapi/perf_phase5_dependency_context.go @@ -0,0 +1,244 @@ +package brokerapi + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "os" + "strings" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/policyengine" + "github.com/runecode-ai/runecode/internal/trustpolicy" + "github.com/runecode-ai/runecode/third_party/jsoncanonicalizer" +) + +func newPhase5DependencyService(repoRoot string) (*Service, func(), error) { + storeRoot, err := os.MkdirTemp("", "runecode-phase5-deps-store-") + if err != nil { + return nil, nil, err + } + ledgerRoot, err := os.MkdirTemp("", "runecode-phase5-deps-ledger-") + if err != nil { + _ = os.RemoveAll(storeRoot) + return nil, nil, err + } + cleanup := func() { + _ = os.RemoveAll(storeRoot) + _ = os.RemoveAll(ledgerRoot) + } + service, err := NewServiceWithConfig(storeRoot, ledgerRoot, APIConfig{RepositoryRoot: repoRoot, DependencyFetch: DependencyFetchConfig{MaxParallelFetches: 8}}) + if err != nil { + cleanup() + return nil, nil, err + } + return service, cleanup, nil +} + +func phase5DependencyAllowlistPayload() ([]byte, error) { + return json.Marshal(map[string]any{ + "schema_id": "runecode.protocol.v0.PolicyAllowlist", + "schema_version": "0.1.0", + "allowlist_kind": "gateway_scope_rule", + "entry_schema_id": "runecode.protocol.v0.GatewayScopeRule", + "entries": []any{phase5DependencyAllowlistEntry()}, + }) +} + +func phase5DependencyAllowlistEntry() map[string]any { + return map[string]any{ + "schema_id": "runecode.protocol.v0.GatewayScopeRule", + "schema_version": "0.1.0", + "scope_kind": "gateway_destination", + "entry_id": "dependency_default", + "gateway_role_kind": "dependency-fetch", + "destination": map[string]any{ + "schema_id": "runecode.protocol.v0.DestinationDescriptor", + "schema_version": "0.1.0", + "descriptor_kind": "package_registry", + "canonical_host": "registry.npmjs.org", + "canonical_path_prefix": "/", + "provider_or_namespace": "npm", + "tls_required": true, + "private_range_blocking": "enforced", + "dns_rebinding_protection": "enforced", + }, + "permitted_operations": []any{"fetch_dependency"}, + "allowed_egress_data_classes": []any{"dependency_resolved_payload"}, + "redirect_posture": "allowlist_only", + "max_timeout_seconds": 120, + "max_response_bytes": 16 << 20, + } +} + +func phase5DependencyRolePayload(runID, allowlistDigest string, verifier trustpolicy.VerifierRecord, privateKey ed25519.PrivateKey) ([]byte, error) { + return phase5SignedPayloadForTrustedContext(map[string]any{ + "schema_id": "runecode.protocol.v0.RoleManifest", + "schema_version": "0.2.0", + "principal": phase5SignedContextPrincipal(runID, "gateway", "dependency-fetch"), + "role_family": "gateway", + "role_kind": "dependency-fetch", + "approval_profile": "moderate", + "capability_opt_ins": []any{"cap_gateway"}, + "allowlist_refs": []any{phase5DigestObject(allowlistDigest)}, + }, verifier, privateKey) +} + +func phase5DependencyRunPayload(runID, allowlistDigest string, verifier trustpolicy.VerifierRecord, privateKey ed25519.PrivateKey) ([]byte, error) { + return phase5SignedPayloadForTrustedContext(map[string]any{ + "schema_id": "runecode.protocol.v0.CapabilityManifest", + "schema_version": "0.2.0", + "principal": phase5SignedContextPrincipal(runID, "gateway", "dependency-fetch"), + "manifest_scope": "run", + "run_id": runID, + "approval_profile": "moderate", + "capability_opt_ins": []any{"cap_gateway"}, + "allowlist_refs": []any{phase5DigestObject(allowlistDigest)}, + }, verifier, privateKey) +} + +func putPhase5TrustedDependencyContext(service *Service, runID string) error { + verifier, privateKey, err := phase5VerifierFixture() + if err != nil { + return err + } + if err := phase5PutTrustedVerifierRecord(service, verifier); err != nil { + return err + } + allowlistPayload, err := phase5DependencyAllowlistPayload() + if err != nil { + return err + } + allowlistDigest, err := phase5PutTrustedPolicyArtifact(service, runID, artifacts.TrustedContractImportKindPolicyAllowlist, allowlistPayload) + if err != nil { + return err + } + rolePayload, err := phase5DependencyRolePayload(runID, allowlistDigest, verifier, privateKey) + if err != nil { + return err + } + runPayload, err := phase5DependencyRunPayload(runID, allowlistDigest, verifier, privateKey) + if err != nil { + return err + } + if _, err := phase5PutTrustedPolicyArtifact(service, runID, artifacts.TrustedContractImportKindRoleManifest, rolePayload); err != nil { + return err + } + if _, err := phase5PutTrustedPolicyArtifact(service, runID, artifacts.TrustedContractImportKindRunCapability, runPayload); err != nil { + return err + } + return nil +} + +func phase5DependencyFetchRegistryRequest(requestID, runID, pkg string) DependencyFetchRegistryRequest { + dep := DependencyFetchRequestObject{ + SchemaID: "runecode.protocol.v0.DependencyFetchRequest", + SchemaVersion: "0.1.0", + RequestKind: "package_version_fetch", + RegistryIdentity: policyengine.DestinationDescriptor{ + SchemaID: "runecode.protocol.v0.DestinationDescriptor", + SchemaVersion: "0.1.0", + DescriptorKind: "package_registry", + CanonicalHost: "registry.npmjs.org", + CanonicalPathPrefix: "/", + ProviderOrNamespace: "npm", + TLSRequired: true, + PrivateRangeBlocking: "enforced", + DNSRebindingProtection: "enforced", + }, + Ecosystem: "npm", + PackageName: "pkg-" + pkg, + PackageVersion: "1.0.0", + } + hash, _ := canonicalDependencyRequestIdentity(dep) + digest, _ := digestFromIdentity(hash) + return DependencyFetchRegistryRequest{SchemaID: "runecode.protocol.v0.DependencyFetchRegistryRequest", SchemaVersion: "0.1.0", RequestID: requestID, RunID: runID, DependencyRequest: dep, RequestHash: digest} +} + +func phase5DependencyEnsureRequest(requestID, runID, pkg string) DependencyCacheEnsureRequest { + depReq := phase5DependencyFetchRegistryRequest(requestID+"-single", runID, pkg).DependencyRequest + batch := DependencyFetchBatchRequestObject{ + SchemaID: "runecode.protocol.v0.DependencyFetchBatchRequest", + SchemaVersion: "0.1.0", + LockfileKind: "generic_lock", + LockfileDigest: mustDigestObjectFromIdentity(artifacts.DigestBytes([]byte("lock:" + pkg))), + RequestSetHash: mustDigestObjectFromIdentity(artifacts.DigestBytes([]byte("request-set:" + pkg))), + DependencyRequests: []DependencyFetchRequestObject{depReq}, + BatchRequestID: "batch-" + pkg, + LockfileLocatorHint: "deps.lock", + } + return DependencyCacheEnsureRequest{SchemaID: "runecode.protocol.v0.DependencyCacheEnsureRequest", SchemaVersion: "0.1.0", RequestID: requestID, RunID: runID, BatchRequest: batch} +} + +func phase5DependencyHandoffRequest(requestID, pkg, consumerRole string) DependencyCacheHandoffRequest { + dep := phase5DependencyFetchRegistryRequest(requestID+"-single", "run-deps-phase5", pkg).DependencyRequest + hash, _ := canonicalDependencyRequestIdentity(dep) + return DependencyCacheHandoffRequest{SchemaID: "runecode.protocol.v0.DependencyCacheHandoffRequest", SchemaVersion: "0.1.0", RequestID: requestID, RequestDigest: mustDigestObjectFromIdentity(hash), ConsumerRole: consumerRole} +} + +func phase5VerifierFixture() (trustpolicy.VerifierRecord, ed25519.PrivateKey, error) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return trustpolicy.VerifierRecord{}, nil, err + } + sum := sha256.Sum256(publicKey) + keyIDValue := hex.EncodeToString(sum[:]) + return trustpolicy.VerifierRecord{SchemaID: trustpolicy.VerifierSchemaID, SchemaVersion: trustpolicy.VerifierSchemaVersion, KeyID: trustpolicy.KeyIDProfile, KeyIDValue: keyIDValue, Alg: "ed25519", PublicKey: trustpolicy.PublicKey{Encoding: "base64", Value: base64.StdEncoding.EncodeToString(publicKey)}, LogicalPurpose: "isolate_session_identity", LogicalScope: "session", OwnerPrincipal: trustpolicy.PrincipalIdentity{SchemaID: "runecode.protocol.v0.PrincipalIdentity", SchemaVersion: "0.2.0", ActorKind: "daemon", PrincipalID: "brokerapi", InstanceID: "brokerapi-1"}, KeyProtectionPosture: "os_keystore", IdentityBindingPosture: "attested", PresenceMode: "os_confirmation", CreatedAt: "2026-03-13T12:00:00Z", Status: "active"}, privateKey, nil +} + +func phase5PutTrustedVerifierRecord(service *Service, record trustpolicy.VerifierRecord) error { + payload, err := json.Marshal(record) + if err != nil { + return err + } + _, err = phase5PutTrustedPolicyArtifact(service, "", artifacts.TrustedContractImportKindVerifierRecord, payload) + return err +} + +func phase5PutTrustedPolicyArtifact(service *Service, runID, kind string, payload []byte) (string, error) { + provenance := "sha256:" + strings.Repeat("1", 64) + ref, err := service.Put(artifacts.PutRequest{Payload: payload, ContentType: "application/json", DataClass: artifacts.DataClassAuditVerificationReport, ProvenanceReceiptHash: provenance, CreatedByRole: "broker", TrustedSource: true, RunID: runID}) + if err != nil { + return "", err + } + details := map[string]interface{}{ + artifacts.TrustedContractImportKindDetailKey: kind, + artifacts.TrustedContractImportArtifactDigestDetailKey: ref.Digest, + artifacts.TrustedContractImportProvenanceDetailKey: provenance, + } + if err := service.AppendTrustedAuditEvent(artifacts.TrustedContractImportAuditEventType, "brokerapi", details); err != nil { + return "", err + } + return ref.Digest, nil +} + +func phase5SignedPayloadForTrustedContext(payload map[string]any, verifier trustpolicy.VerifierRecord, privateKey ed25519.PrivateKey) ([]byte, error) { + payload["signatures"] = []any{} + clone := map[string]any{} + for k, v := range payload { + clone[k] = v + } + delete(clone, "signatures") + raw, err := json.Marshal(clone) + if err != nil { + return nil, err + } + canonical, err := jsoncanonicalizer.Transform(raw) + if err != nil { + return nil, err + } + sig := ed25519.Sign(privateKey, canonical) + payload["signatures"] = []any{map[string]any{"alg": "ed25519", "key_id": verifier.KeyID, "key_id_value": verifier.KeyIDValue, "signature": base64.StdEncoding.EncodeToString(sig)}} + return json.Marshal(payload) +} + +func phase5SignedContextPrincipal(runID, roleFamily, roleKind string) map[string]any { + return map[string]any{"schema_id": "runecode.protocol.v0.PrincipalIdentity", "schema_version": "0.2.0", "actor_kind": "role_instance", "principal_id": "brokerapi", "instance_id": "brokerapi-1", "role_family": roleFamily, "role_kind": roleKind, "run_id": runID} +} + +func phase5DigestObject(identity string) map[string]any { + return map[string]any{"hash_alg": "sha256", "hash": strings.TrimPrefix(identity, "sha256:")} +} diff --git a/internal/brokerapi/perf_phase5_dependency_fetchers.go b/internal/brokerapi/perf_phase5_dependency_fetchers.go new file mode 100644 index 00000000..89950148 --- /dev/null +++ b/internal/brokerapi/perf_phase5_dependency_fetchers.go @@ -0,0 +1,112 @@ +package brokerapi + +import ( + "context" + "fmt" + "io" + "runtime" + "strings" + "sync" + "sync/atomic" + + "github.com/runecode-ai/runecode/internal/artifacts" +) + +type runtimeMem struct{ allocBytes uint64 } + +func (m *runtimeMem) capture() { + var ms runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&ms) + m.allocBytes = ms.Alloc +} + +func (m runtimeMem) allocMB() float64 { + return float64(m.allocBytes) / (1024.0 * 1024.0) +} + +type phase5CountingFetcher struct { + payload string + calls atomic.Int64 +} + +func (f *phase5CountingFetcher) Fetch(_ context.Context, _ DependencyFetchRequestObject, lease dependencyRegistryAuthLease) (io.ReadCloser, dependencyRegistryFetchMetadata, error) { + if lease == nil { + return nil, dependencyRegistryFetchMetadata{}, fmt.Errorf("auth lease required") + } + f.calls.Add(1) + payload := f.payload + if payload == "" { + payload = "phase5-default-payload" + } + return io.NopCloser(strings.NewReader(payload)), dependencyRegistryFetchMetadata{ContentType: "application/octet-stream", ExpectedPayloadDigest: artifacts.DigestBytes([]byte(payload))}, nil +} + +type phase5GatedFetcher struct { + gate chan struct{} + started chan struct{} + once sync.Once + calls atomic.Int64 +} + +func (f *phase5GatedFetcher) Fetch(_ context.Context, _ DependencyFetchRequestObject, lease dependencyRegistryAuthLease) (io.ReadCloser, dependencyRegistryFetchMetadata, error) { + if lease == nil { + return nil, dependencyRegistryFetchMetadata{}, fmt.Errorf("auth lease required") + } + f.calls.Add(1) + f.once.Do(func() { close(f.started) }) + <-f.gate + payload := "phase5-coalesced-payload" + return io.NopCloser(strings.NewReader(payload)), dependencyRegistryFetchMetadata{ContentType: "application/octet-stream", ExpectedPayloadDigest: artifacts.DigestBytes([]byte(payload))}, nil +} + +type phase5ChunkBoundFetcher struct { + payloadSize int64 + maxReadBuf int + maxSeenBuf atomic.Int64 + readCalls atomic.Int64 +} + +func (f *phase5ChunkBoundFetcher) Fetch(_ context.Context, _ DependencyFetchRequestObject, lease dependencyRegistryAuthLease) (io.ReadCloser, dependencyRegistryFetchMetadata, error) { + if lease == nil { + return nil, dependencyRegistryFetchMetadata{}, fmt.Errorf("auth lease required") + } + reader := &phase5ChunkReader{remaining: f.payloadSize, maxReadBuf: f.maxReadBuf, maxSeenBuf: &f.maxSeenBuf, readCalls: &f.readCalls} + return io.NopCloser(reader), dependencyRegistryFetchMetadata{ContentType: "application/octet-stream"}, nil +} + +type phase5ChunkReader struct { + remaining int64 + maxReadBuf int + maxSeenBuf *atomic.Int64 + readCalls *atomic.Int64 +} + +func (r *phase5ChunkReader) Read(p []byte) (int, error) { + if r.remaining <= 0 { + return 0, io.EOF + } + if len(p) > r.maxReadBuf { + return 0, fmt.Errorf("oversized read buffer") + } + r.phase5RememberSeenBuffer(int64(len(p))) + r.readCalls.Add(1) + n := len(p) + if int64(n) > r.remaining { + n = int(r.remaining) + } + for i := 0; i < n; i++ { + p[i] = 'x' + } + r.remaining -= int64(n) + return n, nil +} + +func (r *phase5ChunkReader) phase5RememberSeenBuffer(seen int64) { + for { + max := r.maxSeenBuf.Load() + if seen <= max || r.maxSeenBuf.CompareAndSwap(max, seen) { + return + } + } +} diff --git a/internal/brokerapi/perf_verification_harness.go b/internal/brokerapi/perf_verification_harness.go new file mode 100644 index 00000000..be669e3b --- /dev/null +++ b/internal/brokerapi/perf_verification_harness.go @@ -0,0 +1,146 @@ +package brokerapi + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/runecode-ai/runecode/internal/perfcontracts" +) + +const phase5PerfCheckSchemaVersion = "runecode.performance.check.v1" + +type Phase5PerformanceHarnessConfig struct { + RepositoryRoot string + Trials int + CommandTimeout time.Duration + CommandRunner func(repoRoot string, timeout time.Duration, command ...string) (float64, error) +} + +func RunPhase5PerformanceHarness(cfg Phase5PerformanceHarnessConfig) (perfcontracts.CheckOutput, error) { + trials := phase5ResolvedTrials(cfg.Trials) + timeout := phase5ResolvedTimeout(cfg.CommandTimeout) + repoRoot, err := phase5ResolveRepoRoot(cfg.RepositoryRoot) + if err != nil { + return perfcontracts.CheckOutput{}, err + } + runner := cfg.CommandRunner + if runner == nil { + runner = phase5RunCommand + } + + measurements, err := phase5CollectMeasurements(trials, repoRoot, timeout, runner) + if err != nil { + return perfcontracts.CheckOutput{}, err + } + return perfcontracts.CheckOutput{SchemaVersion: phase5PerfCheckSchemaVersion, Measurements: measurements}, nil +} + +func phase5ResolvedTrials(trials int) int { + if trials <= 0 { + return 10 + } + return trials +} + +func phase5ResolvedTimeout(timeout time.Duration) time.Duration { + if timeout <= 0 { + return 2 * time.Minute + } + return timeout +} + +func phase5ResolveRepoRoot(explicit string) (string, error) { + repoRoot := strings.TrimSpace(explicit) + if repoRoot != "" { + return repoRoot, nil + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return cwd, nil +} + +func phase5CollectMeasurements( + trials int, + repoRoot string, + timeout time.Duration, + runner func(repoRoot string, timeout time.Duration, command ...string) (float64, error), +) ([]perfcontracts.MeasurementRecord, error) { + measurements := make([]perfcontracts.MeasurementRecord, 0, 24) + + if err := phase5AppendGatewayAndSecrets(&measurements, trials, repoRoot); err != nil { + return nil, err + } + if err := phase5AppendDependencyFlow(&measurements, trials, repoRoot); err != nil { + return nil, err + } + if err := phase5AppendAuditVerification(&measurements, trials, repoRoot, timeout, runner); err != nil { + return nil, err + } + if err := phase5AppendProtocolChecks(&measurements, repoRoot, timeout, runner); err != nil { + return nil, err + } + measurements = append(measurements, measurePhase5ExternalAnchorStubbed(trials)...) + return measurements, nil +} + +func phase5AppendGatewayAndSecrets(measurements *[]perfcontracts.MeasurementRecord, trials int, repoRoot string) error { + items, err := measurePhase5GatewayAndSecrets(trials, repoRoot) + if err != nil { + return err + } + *measurements = append(*measurements, items...) + return nil +} + +func phase5AppendDependencyFlow(measurements *[]perfcontracts.MeasurementRecord, trials int, repoRoot string) error { + items, err := measurePhase5DependencyFlow(trials, repoRoot) + if err != nil { + return err + } + *measurements = append(*measurements, items...) + return nil +} + +func phase5AppendAuditVerification( + measurements *[]perfcontracts.MeasurementRecord, + trials int, + repoRoot string, + timeout time.Duration, + runner func(repoRoot string, timeout time.Duration, command ...string) (float64, error), +) error { + items, err := measurePhase5AuditVerification(trials, repoRoot, timeout, runner) + if err != nil { + return err + } + *measurements = append(*measurements, items...) + return nil +} + +func phase5AppendProtocolChecks( + measurements *[]perfcontracts.MeasurementRecord, + repoRoot string, + timeout time.Duration, + runner func(repoRoot string, timeout time.Duration, command ...string) (float64, error), +) error { + items, err := measurePhase5ProtocolChecks(repoRoot, timeout, runner) + if err != nil { + return err + } + *measurements = append(*measurements, items...) + return nil +} + +func phase5DependencyMetric(metricID string, value float64, unit string) perfcontracts.MeasurementRecord { + return perfcontracts.MeasurementRecord{MetricID: metricID, Value: value, Unit: unit} +} + +func phase5DependencyErr(action string, errResp *ErrorResponse) error { + if errResp == nil { + return nil + } + return fmt.Errorf("%s: %s", action, errResp.Error.Code) +} diff --git a/internal/brokerapi/perf_verification_harness_test.go b/internal/brokerapi/perf_verification_harness_test.go new file mode 100644 index 00000000..b9bb7481 --- /dev/null +++ b/internal/brokerapi/perf_verification_harness_test.go @@ -0,0 +1,163 @@ +package brokerapi + +import ( + "testing" + "time" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/perfcontracts" +) + +func TestPhase5GatewayPerfRigMeasuresGatewayAdmissionAndIngressPaths(t *testing.T) { + rig, err := newPhase5GatewayPerfRig("") + if err != nil { + t.Fatalf("newPhase5GatewayPerfRig returned error: %v", err) + } + defer rig.cleanup() + + if err := rig.invokeGatewayTrial(1); err != nil { + t.Fatalf("invokeGatewayTrial returned error: %v", err) + } + if err := rig.issueLeaseTrial(1); err != nil { + t.Fatalf("issueLeaseTrial returned error: %v", err) + } + if err := rig.ingressPrepareSubmitTrial(1); err != nil { + t.Fatalf("ingressPrepareSubmitTrial returned error: %v", err) + } + + events, err := rig.service.ReadAuditEvents() + if err != nil { + t.Fatalf("ReadAuditEvents returned error: %v", err) + } + if !hasAuditEventType(events, "model_egress") { + t.Fatal("missing model_egress audit event from gateway invoke trial") + } + if !hasAuditEventType(events, brokerAuditEventTypeProviderCredential) { + t.Fatal("missing provider credential audit event from ingress submit trial") + } +} + +func TestRunPhase5PerformanceHarnessProducesExpectedMetrics(t *testing.T) { + out, err := RunPhase5PerformanceHarness(testPhase5HarnessConfig()) + if err != nil { + t.Fatalf("RunPhase5PerformanceHarness returned error: %v", err) + } + if out.SchemaVersion != phase5PerfCheckSchemaVersion { + t.Fatalf("schema_version = %q, want %q", out.SchemaVersion, phase5PerfCheckSchemaVersion) + } + required := requiredPhase5Metrics() + for metricID, unit := range required { + if !hasPhase5Metric(out.Measurements, metricID, unit) { + t.Fatalf("missing metric %s (%s)", metricID, unit) + } + } +} + +func TestMeasurePhase5AuditVerificationUsesMedianOfTrials(t *testing.T) { + t.Parallel() + + verifySamples := []float64{900, 240, 210, 220} + finalizeSamples := []float64{1200, 700, 650, 620} + verifyCalls := 0 + finalizeCalls := 0 + measurements, err := measurePhase5AuditVerification(3, "/repo", time.Second, func(_ string, _ time.Duration, command ...string) (float64, error) { + switch command[2] { + case "./internal/auditd": + value := verifySamples[verifyCalls] + verifyCalls++ + return value, nil + case "./internal/brokerapi": + value := finalizeSamples[finalizeCalls] + finalizeCalls++ + return value, nil + default: + t.Fatalf("unexpected command: %v", command) + return 0, nil + } + }) + if err != nil { + t.Fatalf("measurePhase5AuditVerification returned error: %v", err) + } + assertMetricValue(t, measurements, "metric.audit.verify_current_segment.wall_ms", 220) + assertMetricValue(t, measurements, "metric.audit.finalize_verify.wall_ms", 650) + if verifyCalls != 4 || finalizeCalls != 4 { + t.Fatalf("verifyCalls=%d finalizeCalls=%d, want 4 each", verifyCalls, finalizeCalls) + } +} + +func testPhase5HarnessConfig() Phase5PerformanceHarnessConfig { + return Phase5PerformanceHarnessConfig{ + Trials: 2, + CommandRunner: func(_ string, _ time.Duration, command ...string) (float64, error) { + return phase5CommandLatency(command), nil + }, + } +} + +func phase5CommandLatency(command []string) float64 { + if len(command) >= 3 && command[0] == "go" && command[1] == "test" { + return 120 + } + if len(command) >= 3 && command[0] == "node" && command[1] == "--test" { + return 90 + } + return 10 +} + +func requiredPhase5Metrics() map[string]string { + return map[string]string{ + "metric.gateway.model_invoke.overhead.p95_ms": "ms", + "metric.secrets.lease_issue.p95_ms": "ms", + "metric.secrets.ingress.prepare_submit.p95_ms": "ms", + "metric.deps.cache_miss.small.wall_ms": "ms", + "metric.deps.cache_hit.small.wall_ms": "ms", + "metric.deps.cache_coalesced.upstream_fetch_count": "count", + "metric.deps.cache_coalesced.cas_write_count": "count", + "metric.deps.materialization.workspace_handoff.wall_ms": "ms", + "metric.deps.stream_to_cas.max_read_buffer_bytes": "bytes", + "metric.deps.stream_to_cas.read_calls": "count", + "metric.deps.cache_fill.peak_alloc_mb": "mb", + "metric.audit.verify_current_segment.wall_ms": "ms", + "metric.audit.finalize_verify.wall_ms": "ms", + "metric.protocol.schema_validation.wall_ms": "ms", + "metric.protocol.fixture_parity.wall_ms": "ms", + "metric.anchor.prepare.latency.p95_ms": "ms", + "metric.anchor.execute.completed.p95_ms": "ms", + "metric.anchor.execute.deferred.handoff.p95_ms": "ms", + "metric.anchor.deferred.visibility.p95_ms": "ms", + "metric.anchor.receipt_admission.unchanged_seal.p95_ms": "ms", + "metric.anchor.network_io_under_ledger_lock.count": "count", + "metric.anchor.verifier_bypass.count": "count", + } +} + +func hasPhase5Metric(measurements []perfcontracts.MeasurementRecord, metricID, unit string) bool { + for _, m := range measurements { + if m.MetricID == metricID && m.Unit == unit { + return true + } + } + return false +} + +func assertMetricValue(t *testing.T, measurements []perfcontracts.MeasurementRecord, metricID string, want float64) { + t.Helper() + for _, measurement := range measurements { + if measurement.MetricID == metricID { + if measurement.Value != want { + t.Fatalf("metric %s value = %v, want %v", metricID, measurement.Value, want) + } + return + } + } + t.Fatalf("metric %s missing", metricID) +} + +func hasAuditEventType(events []artifacts.AuditEvent, eventType string) bool { + for _, event := range events { + if event.Type == eventType { + return true + } + } + return false +} diff --git a/internal/brokerapi/project_substrate_lifecycle_follow_up_test.go b/internal/brokerapi/project_substrate_lifecycle_follow_up_test.go new file mode 100644 index 00000000..ff719a4e --- /dev/null +++ b/internal/brokerapi/project_substrate_lifecycle_follow_up_test.go @@ -0,0 +1,178 @@ +package brokerapi + +import ( + "context" + "testing" +) + +func TestHandleProjectSubstrateInitApplyRevalidatesFollowUpPostureAndStatus(t *testing.T) { + repoRoot := t.TempDir() + service := newBrokerAPIServiceForTests(t, APIConfig{RepositoryRoot: repoRoot}) + previewResp := assertProjectSubstrateInitPreviewReady(t, service) + assertProjectSubstrateInitApplyApplied(t, service, previewResp.Preview.PreviewToken) + + postureResp := mustProjectSubstratePostureGet(t, service, "req-project-substrate-posture-after-init-apply") + if got := postureResp.PostureSummary.ValidationState; got != "valid" { + t.Fatalf("posture_summary.validation_state = %q, want valid", got) + } + if got := postureResp.PostureSummary.CompatibilityPosture; got != "supported_current" { + t.Fatalf("posture_summary.compatibility_posture = %q, want supported_current", got) + } + if !postureResp.PostureSummary.NormalOperationAllowed { + t.Fatal("posture_summary.normal_operation_allowed = false, want true") + } + + readinessResp := mustReadinessGetForProjectSubstrateSmoke(t, service, "req-project-substrate-readiness-after-init-apply") + if readinessResp.Readiness.ProjectSubstrateSummary == nil { + t.Fatal("readiness.project_substrate_posture_summary = nil, want projection after init apply") + } + if got := readinessResp.Readiness.ProjectSubstrateSummary.CompatibilityPosture; got != "supported_current" { + t.Fatalf("readiness.project_substrate_posture_summary.compatibility_posture = %q, want supported_current", got) + } + if !readinessResp.Readiness.ProjectSubstrateSummary.NormalOperationAllowed { + t.Fatal("readiness.project_substrate_posture_summary.normal_operation_allowed = false, want true") + } +} + +func TestHandleProjectSubstrateUpgradeApplyRevalidatesFollowUpPostureAndStatus(t *testing.T) { + repoRoot := t.TempDir() + writeProjectSubstrateAnchors(t, repoRoot, "0.1.0-alpha.13", "verified", "runecontext") + service := newBrokerAPIServiceForTests(t, APIConfig{RepositoryRoot: repoRoot}) + previewResp := mustProjectSubstrateUpgradePreview(t, service, "req-project-substrate-upgrade-preview-follow-up") + applyProjectSubstrateUpgradePreview(t, service, "req-project-substrate-upgrade-apply-follow-up", previewResp.Preview.PreviewDigest) + + postureResp := mustProjectSubstratePostureGet(t, service, "req-project-substrate-posture-after-upgrade-apply") + if got := postureResp.PostureSummary.ValidationState; got != "valid" { + t.Fatalf("posture_summary.validation_state = %q, want valid", got) + } + if got := postureResp.PostureSummary.CompatibilityPosture; got != "supported_current" { + t.Fatalf("posture_summary.compatibility_posture = %q, want supported_current", got) + } + if got := postureResp.UpgradePreview.Status; got != "noop" { + t.Fatalf("upgrade_preview.status = %q, want noop after upgrade apply", got) + } + + readinessResp := mustReadinessGetForProjectSubstrateSmoke(t, service, "req-project-substrate-readiness-after-upgrade-apply") + if readinessResp.Readiness.ProjectSubstrateSummary == nil { + t.Fatal("readiness.project_substrate_posture_summary = nil, want projection after upgrade apply") + } + if got := readinessResp.Readiness.ProjectSubstrateSummary.CompatibilityPosture; got != "supported_current" { + t.Fatalf("readiness.project_substrate_posture_summary.compatibility_posture = %q, want supported_current", got) + } + if !readinessResp.Readiness.ProjectSubstrateSummary.NormalOperationAllowed { + t.Fatal("readiness.project_substrate_posture_summary.normal_operation_allowed = false, want true") + } +} + +func mustProjectSubstrateUpgradePreview(t *testing.T, service *Service, requestID string) ProjectSubstrateUpgradePreviewResponse { + t.Helper() + resp, errResp := service.HandleProjectSubstrateUpgradePreview(context.Background(), ProjectSubstrateUpgradePreviewRequest{SchemaID: "runecode.protocol.v0.ProjectSubstrateUpgradePreviewRequest", SchemaVersion: "0.1.0", RequestID: requestID}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleProjectSubstrateUpgradePreview returned error: %+v", errResp) + } + return resp +} + +func applyProjectSubstrateUpgradePreview(t *testing.T, service *Service, requestID, previewDigest string) { + t.Helper() + _, errResp := service.HandleProjectSubstrateUpgradeApply(context.Background(), ProjectSubstrateUpgradeApplyRequest{SchemaID: "runecode.protocol.v0.ProjectSubstrateUpgradeApplyRequest", SchemaVersion: "0.1.0", RequestID: requestID, ExpectedPreviewDigest: previewDigest}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleProjectSubstrateUpgradeApply returned error: %+v", errResp) + } +} + +func TestProjectSubstrateLifecycleSmokeCoversInitAndUpgradeProductSurfaces(t *testing.T) { + t.Run("init lifecycle", func(t *testing.T) { + assertProjectSubstrateInitLifecycleSmoke(t) + }) + + t.Run("adopt and upgrade lifecycle", func(t *testing.T) { + assertProjectSubstrateAdoptAndUpgradeLifecycleSmoke(t) + }) +} + +func assertProjectSubstrateInitLifecycleSmoke(t *testing.T) { + t.Helper() + repoRoot := t.TempDir() + service := newBrokerAPIServiceForTests(t, APIConfig{RepositoryRoot: repoRoot}) + assertProjectSubstrateGetMissing(t, service) + postureResp := mustProjectSubstratePostureGet(t, service, "req-project-substrate-smoke-init-posture-before") + if postureResp.PostureSummary.NormalOperationAllowed { + t.Fatal("normal_operation_allowed before init = true, want false") + } + assertProjectSubstrateAdoptBlocked(t, service) + previewResp := assertProjectSubstrateInitPreviewReady(t, service) + assertProjectSubstrateInitApplyApplied(t, service, previewResp.Preview.PreviewToken) + assertProjectSubstrateGetValid(t, service) + postureResp = mustProjectSubstratePostureGet(t, service, "req-project-substrate-smoke-init-posture-after") + if got := postureResp.PostureSummary.CompatibilityPosture; got != "supported_current" { + t.Fatalf("compatibility_posture after init = %q, want supported_current", got) + } + if !postureResp.PostureSummary.NormalOperationAllowed { + t.Fatal("normal_operation_allowed after init = false, want true") + } + readinessResp := mustReadinessGetForProjectSubstrateSmoke(t, service, "req-project-substrate-smoke-init-readiness") + if readinessResp.Readiness.ProjectSubstrateSummary == nil { + t.Fatal("readiness.project_substrate_summary = nil, want broker-owned summary after init") + } + if got := readinessResp.Readiness.ProjectSubstrateSummary.ValidationState; got != "valid" { + t.Fatalf("readiness project substrate validation_state = %q, want valid", got) + } +} + +func assertProjectSubstrateAdoptAndUpgradeLifecycleSmoke(t *testing.T) { + t.Helper() + repoRoot := t.TempDir() + writeProjectSubstrateAnchors(t, repoRoot, "0.1.0-alpha.13", "verified", "runecontext") + service := newBrokerAPIServiceForTests(t, APIConfig{RepositoryRoot: repoRoot}) + adoptResp, errResp := service.HandleProjectSubstrateAdopt(context.Background(), ProjectSubstrateAdoptRequest{SchemaID: "runecode.protocol.v0.ProjectSubstrateAdoptRequest", SchemaVersion: "0.1.0", RequestID: "req-project-substrate-smoke-adopt"}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleProjectSubstrateAdopt returned error: %+v", errResp) + } + if got := adoptResp.Adoption.Status; got != "adopted" { + t.Fatalf("adoption.status = %q, want adopted", got) + } + postureResp := mustProjectSubstratePostureGet(t, service, "req-project-substrate-smoke-upgrade-before") + if got := postureResp.UpgradePreview.Status; got != "ready_for_apply" { + t.Fatalf("upgrade_preview.status before upgrade = %q, want ready_for_apply", got) + } + previewResp, errResp := service.HandleProjectSubstrateUpgradePreview(context.Background(), ProjectSubstrateUpgradePreviewRequest{SchemaID: "runecode.protocol.v0.ProjectSubstrateUpgradePreviewRequest", SchemaVersion: "0.1.0", RequestID: "req-project-substrate-smoke-upgrade-preview"}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleProjectSubstrateUpgradePreview returned error: %+v", errResp) + } + if got := previewResp.Preview.Status; got != "ready_for_apply" { + t.Fatalf("upgrade preview status = %q, want ready_for_apply", got) + } + applyResp, errResp := service.HandleProjectSubstrateUpgradeApply(context.Background(), ProjectSubstrateUpgradeApplyRequest{SchemaID: "runecode.protocol.v0.ProjectSubstrateUpgradeApplyRequest", SchemaVersion: "0.1.0", RequestID: "req-project-substrate-smoke-upgrade-apply", ExpectedPreviewDigest: previewResp.Preview.PreviewDigest}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleProjectSubstrateUpgradeApply returned error: %+v", errResp) + } + if got := applyResp.ApplyResult.Status; got != "applied" { + t.Fatalf("upgrade apply status = %q, want applied", got) + } + postureResp = mustProjectSubstratePostureGet(t, service, "req-project-substrate-smoke-upgrade-after") + if got := postureResp.PostureSummary.CompatibilityPosture; got != "supported_current" { + t.Fatalf("compatibility_posture after upgrade = %q, want supported_current", got) + } + if got := postureResp.UpgradePreview.Status; got != "noop" { + t.Fatalf("upgrade_preview.status after upgrade = %q, want noop", got) + } +} + +func mustProjectSubstratePostureGet(t *testing.T, service *Service, requestID string) ProjectSubstratePostureGetResponse { + t.Helper() + resp, errResp := service.HandleProjectSubstratePostureGet(context.Background(), ProjectSubstratePostureGetRequest{SchemaID: "runecode.protocol.v0.ProjectSubstratePostureGetRequest", SchemaVersion: "0.1.0", RequestID: requestID}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleProjectSubstratePostureGet returned error: %+v", errResp) + } + return resp +} + +func mustReadinessGetForProjectSubstrateSmoke(t *testing.T, service *Service, requestID string) ReadinessGetResponse { + t.Helper() + resp, errResp := service.HandleReadinessGet(context.Background(), ReadinessGetRequest{SchemaID: "runecode.protocol.v0.ReadinessGetRequest", SchemaVersion: "0.1.0", RequestID: requestID}, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleReadinessGet returned error: %+v", errResp) + } + return resp +} diff --git a/internal/brokerapi/project_substrate_lifecycle_test.go b/internal/brokerapi/project_substrate_lifecycle_test.go index a3b41e34..5fd2ed12 100644 --- a/internal/brokerapi/project_substrate_lifecycle_test.go +++ b/internal/brokerapi/project_substrate_lifecycle_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/runecode-ai/runecode/internal/artifacts" "github.com/runecode-ai/runecode/internal/projectsubstrate" ) @@ -85,6 +86,36 @@ func TestHandleProjectSubstrateAdoptBlocksUnsupportedCompatibility(t *testing.T) } } +func TestHandleProjectSubstrateAdoptCompatibleExistingIsReadOnly(t *testing.T) { + repoRoot := t.TempDir() + writeProjectSubstrateAnchors(t, repoRoot, "0.1.0-alpha.14", "verified", "runecontext") + configPath := filepath.Join(repoRoot, "runecontext.yaml") + before, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile(before) returned error: %v", err) + } + + service := newBrokerAPIServiceForTests(t, APIConfig{RepositoryRoot: repoRoot}) + resp, errResp := service.HandleProjectSubstrateAdopt(context.Background(), ProjectSubstrateAdoptRequest{ + SchemaID: "runecode.protocol.v0.ProjectSubstrateAdoptRequest", + SchemaVersion: "0.1.0", + RequestID: "req-project-substrate-adopt-compatible-read-only", + }, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleProjectSubstrateAdopt returned error: %+v", errResp) + } + if got := resp.Adoption.Status; got != "adopted" { + t.Fatalf("adoption.status = %q, want adopted", got) + } + after, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile(after) returned error: %v", err) + } + if string(after) != string(before) { + t.Fatalf("runecontext.yaml mutated during read-only adopt:\nbefore:\n%s\nafter:\n%s", string(before), string(after)) + } +} + func TestHandleProjectSubstrateUpgradePreviewAndApply(t *testing.T) { root := t.TempDir() writeProjectSubstrateAnchors(t, root, "0.1.0-alpha.14", "plain", "runecontext") @@ -122,6 +153,68 @@ func TestHandleProjectSubstrateUpgradePreviewAndApply(t *testing.T) { } } +func TestHandleProjectSubstrateApplyOperationsAppendTrustedAuditEvents(t *testing.T) { + initRoot := t.TempDir() + initService := newBrokerAPIServiceForTests(t, APIConfig{RepositoryRoot: initRoot}) + initPreview := assertProjectSubstrateInitPreviewReady(t, initService) + assertProjectSubstrateInitApplyApplied(t, initService, initPreview.Preview.PreviewToken) + initEvents, err := initService.ReadAuditEvents() + if err != nil { + t.Fatalf("ReadAuditEvents(init) returned error: %v", err) + } + if !auditEventContainsValue(initEvents, "project_substrate_init_event", "preview_token", initPreview.Preview.PreviewToken) { + t.Fatalf("init apply audit event missing preview token %q", initPreview.Preview.PreviewToken) + } + assertProjectSubstrateUpgradeAuditEvent(t) +} + +func assertProjectSubstrateUpgradeAuditEvent(t *testing.T) { + t.Helper() + upgradeRoot := t.TempDir() + writeProjectSubstrateAnchors(t, upgradeRoot, "0.1.0-alpha.13", "verified", "runecontext") + upgradeService := newBrokerAPIServiceForTests(t, APIConfig{RepositoryRoot: upgradeRoot}) + upgradePreviewResp, errResp := upgradeService.HandleProjectSubstrateUpgradePreview(context.Background(), ProjectSubstrateUpgradePreviewRequest{ + SchemaID: "runecode.protocol.v0.ProjectSubstrateUpgradePreviewRequest", + SchemaVersion: "0.1.0", + RequestID: "req-project-substrate-upgrade-preview-audit", + }, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleProjectSubstrateUpgradePreview returned error: %+v", errResp) + } + _, errResp = upgradeService.HandleProjectSubstrateUpgradeApply(context.Background(), ProjectSubstrateUpgradeApplyRequest{ + SchemaID: "runecode.protocol.v0.ProjectSubstrateUpgradeApplyRequest", + SchemaVersion: "0.1.0", + RequestID: "req-project-substrate-upgrade-apply-audit", + ExpectedPreviewDigest: upgradePreviewResp.Preview.PreviewDigest, + }, RequestContext{}) + if errResp != nil { + t.Fatalf("HandleProjectSubstrateUpgradeApply returned error: %+v", errResp) + } + upgradeEvents, err := upgradeService.ReadAuditEvents() + if err != nil { + t.Fatalf("ReadAuditEvents(upgrade) returned error: %v", err) + } + if !auditEventContainsValue(upgradeEvents, "project_substrate_upgrade_event", "preview_digest", upgradePreviewResp.Preview.PreviewDigest) { + t.Fatalf("upgrade apply audit event missing preview digest %q", upgradePreviewResp.Preview.PreviewDigest) + } +} + +func auditEventContainsValue(events []artifacts.AuditEvent, eventType, key, want string) bool { + for _, event := range events { + if strings.TrimSpace(event.Type) != strings.TrimSpace(eventType) { + continue + } + value, ok := event.Details[key] + if !ok { + continue + } + if strings.TrimSpace(fmt.Sprintf("%v", value)) == strings.TrimSpace(want) { + return true + } + } + return false +} + func TestHandleProjectSubstrateApplyReturnsSuccessWhenRefreshFails(t *testing.T) { repoRoot := t.TempDir() service := newBrokerAPIServiceForTests(t, APIConfig{RepositoryRoot: repoRoot}) diff --git a/internal/brokerapi/service.go b/internal/brokerapi/service.go index 931acc7b..17194966 100644 --- a/internal/brokerapi/service.go +++ b/internal/brokerapi/service.go @@ -50,6 +50,7 @@ type Service struct { dependencyFetchService *dependencyFetchService runGatePlanCache *runGatePlanCache compileCoordinator *compileCoordinator + sessionExecutionRunner sessionExecutionRunnerLaunchFunc externalAnchorRuntime externalAnchorExecutionRuntime externalAnchorQueue *externalAnchorBackgroundQueue } @@ -128,6 +129,7 @@ func newConfiguredService(store *artifacts.Store, ledger *auditd.Ledger, ledgerR versionInfo: defaultBrokerVersionInfo(), runGatePlanCache: newRunGatePlanCache(), compileCoordinator: newCompileCoordinator(cfg.Compile.MaxParallelCompiles), + sessionExecutionRunner: launchSessionExecutionRunnerSubprocess, externalAnchorRuntime: externalAnchorExecutionRuntimeDeterministic{}, externalAnchorQueue: newExternalAnchorBackgroundQueue(), } diff --git a/internal/brokerapi/service_runtime_audit_details.go b/internal/brokerapi/service_runtime_audit_details.go index ad54ec30..f5053ae1 100644 --- a/internal/brokerapi/service_runtime_audit_details.go +++ b/internal/brokerapi/service_runtime_audit_details.go @@ -12,7 +12,8 @@ func runtimeAuditDetailsForPayload(eventType, payloadSchemaID string, payload an if err != nil { return nil, err } - attestationReasonCodes := runtimeAttestationReasonCodes(evidence) + attestationPosture := runtimeAttestationPosture(evidence) + attestationReasonCodes := runtimeAttestationReasonCodesForPosture(attestationPosture, evidence) details := map[string]interface{}{ "audit_event_type": eventType, "event_payload_schema_id": payloadSchemaID, @@ -22,9 +23,11 @@ func runtimeAuditDetailsForPayload(eventType, payloadSchemaID string, payload an "evidence_digest_refs": runtimeEvidenceDigestRefs(evidence), "stored_runtime_fact_digests": runtimeStoredDigestMap(evidence), "provisioning_posture": evidence.Launch.ProvisioningPosture, - "attestation_posture": runtimeAttestationPosture(evidence), + "attestation_posture": attestationPosture, "attestation_verifier_class": runtimeAttestationVerifierClass(evidence), - "attestation_reason_codes": attestationReasonCodes, + } + if len(attestationReasonCodes) > 0 { + details["attestation_reason_codes"] = attestationReasonCodes } mergeRuntimeSupportAuditDetails(details, runtimeSupportState) if sessionID := strings.TrimSpace(evidence.Launch.SessionID); sessionID != "" { @@ -79,6 +82,13 @@ func runtimeAttestationReasonCodes(evidence launcherbackend.RuntimeEvidenceSnaps return append([]string{}, reasons...) } +func runtimeAttestationReasonCodesForPosture(posture string, evidence launcherbackend.RuntimeEvidenceSnapshot) []string { + if posture != launcherbackend.AttestationPostureInvalid { + return nil + } + return runtimeAttestationReasonCodes(evidence) +} + func runtimeAttestationVerifierClass(evidence launcherbackend.RuntimeEvidenceSnapshot) string { return launcherbackend.DeriveAttestationVerifierClassFromEvidence(evidence) } diff --git a/internal/brokerapi/service_runtime_facts.go b/internal/brokerapi/service_runtime_facts.go index 5b3b5849..001e2ada 100644 --- a/internal/brokerapi/service_runtime_facts.go +++ b/internal/brokerapi/service_runtime_facts.go @@ -23,27 +23,32 @@ func (s *Service) RecordRuntimeFacts(runID string, facts launcherbackend.Runtime if err := s.store.RecordRuntimeEvidenceState(normalizedRunID, facts, evidence, lifecycle); err != nil { return err } - if err := s.syncRunStatusFromRuntimeFacts(normalizedRunID, facts); err != nil { + persistedFacts, persistedEvidence, _, _, ok := s.store.RuntimeEvidenceState(normalizedRunID) + if !ok { + return fmt.Errorf("persisted runtime evidence state missing for run %q", normalizedRunID) + } + if err := s.syncRunStatusFromRuntimeFacts(normalizedRunID, persistedFacts); err != nil { return err } - runtimeSupportState := runtimeAuditSupportState(evidence, s.currentInstanceBackendPosture().InstanceID, normalizedRunID, s.PolicyDecisionRefsForRun(normalizedRunID), s.listApprovals()) + runtimeSupportState := runtimeAuditSupportState(persistedEvidence, s.currentInstanceBackendPosture().InstanceID, normalizedRunID, s.PolicyDecisionRefsForRun(normalizedRunID), s.listApprovals()) runnerAdvisory, _ := s.RunnerAdvisory(normalizedRunID) - if err := s.SyncSessionExecutionFromRunRuntime(normalizedRunID, facts, runnerAdvisory, s.now().UTC()); err != nil { + if err := s.SyncSessionExecutionFromRunRuntime(normalizedRunID, persistedFacts, runnerAdvisory, s.now().UTC()); err != nil { return err } - if err := s.emitRuntimeEvidenceAuditEvents(normalizedRunID, facts, evidence, runtimeSupportState); err != nil { + if err := s.emitRuntimeEvidenceAuditEvents(normalizedRunID, persistedFacts, persistedEvidence, runtimeSupportState); err != nil { return err } return nil } func (s *Service) RuntimeFacts(runID string) launcherbackend.RuntimeFactsSnapshot { - facts, _, lifecycle, _, ok := s.store.RuntimeEvidenceState(runID) + facts, evidence, lifecycle, _, ok := s.store.RuntimeEvidenceState(runID) if !ok { return launcherbackend.DefaultRuntimeFacts(runID) } facts = normalizeRuntimeFactsSnapshot(runID, facts) applyPersistedLifecycle(&facts, lifecycle) + facts.LaunchReceipt.ProvisioningPosture = authoritativeRuntimeProvisioningPosture(facts.LaunchReceipt.ProvisioningPosture, evidence) return facts } @@ -58,6 +63,7 @@ func (s *Service) RuntimeEvidence(runID string) launcherbackend.RuntimeEvidenceS func normalizeRuntimeFactsSnapshot(runID string, input launcherbackend.RuntimeFactsSnapshot) launcherbackend.RuntimeFactsSnapshot { facts := input facts.LaunchReceipt = facts.LaunchReceipt.Normalized() + facts.PostHandshakeAttestationInput = launcherbackend.NormalizePostHandshakeRuntimeAttestationInput(facts.PostHandshakeAttestationInput) facts.HardeningPosture = normalizeRuntimeHardeningPosture(facts.HardeningPosture, facts.LaunchReceipt) facts.TerminalReport = normalizeRuntimeTerminalReport(facts.TerminalReport) if facts.LaunchReceipt.RunID == "" { @@ -78,12 +84,20 @@ func applyPersistedLifecycle(facts *launcherbackend.RuntimeFactsSnapshot, lifecy facts.LaunchReceipt.ProvisioningPosture = lifecycle.ProvisioningPosture } facts.LaunchReceipt.ProvisioningPostureDegraded = lifecycle.ProvisioningPostureDegraded - if len(lifecycle.ProvisioningDegradedReasons) > 0 { - facts.LaunchReceipt.ProvisioningDegradedReasons = append([]string{}, lifecycle.ProvisioningDegradedReasons...) + facts.LaunchReceipt.ProvisioningDegradedReasons = append([]string{}, lifecycle.ProvisioningDegradedReasons...) + facts.LaunchReceipt.LaunchFailureReasonCode = strings.TrimSpace(lifecycle.LaunchFailureReasonCode) +} + +func authoritativeRuntimeProvisioningPosture(current string, evidence launcherbackend.RuntimeEvidenceSnapshot) string { + posture := strings.TrimSpace(current) + if posture != launcherbackend.ProvisioningPostureAttested { + return posture } - if strings.TrimSpace(lifecycle.LaunchFailureReasonCode) != "" { - facts.LaunchReceipt.LaunchFailureReasonCode = lifecycle.LaunchFailureReasonCode + attestationPosture, _ := launcherbackend.DeriveAttestationPostureFromEvidence(evidence) + if attestationPosture == launcherbackend.AttestationPostureValid { + return launcherbackend.ProvisioningPostureAttested } + return launcherbackend.ProvisioningPostureTOFU } func normalizeRuntimeHardeningPosture(input launcherbackend.AppliedHardeningPosture, receipt launcherbackend.BackendLaunchReceipt) launcherbackend.AppliedHardeningPosture { diff --git a/internal/brokerapi/session_execution_identifiers.go b/internal/brokerapi/session_execution_identifiers.go new file mode 100644 index 00000000..a87a5959 --- /dev/null +++ b/internal/brokerapi/session_execution_identifiers.go @@ -0,0 +1,74 @@ +package brokerapi + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strconv" + "strings" +) + +const ( + maxSessionExecutionRunIDLength = 128 + maxSessionExecutionPlanIDLength = 128 - len(runPlanAuthorityStepPrefix) + maxSessionExecutionAttemptIDLen = 128 +) + +func sessionExecutionRunID(sessionID string, executionIndex int) string { + return sessionExecutionScopedID("run", sessionID, executionIndex, maxSessionExecutionRunIDLength) +} + +func sessionExecutionDerivedPlanID(sourceID string, executionIndex int) string { + return sessionExecutionScopedID("plan", sourceID, executionIndex, maxSessionExecutionPlanIDLength) +} + +func sessionExecutionDerivedAttemptID(prefix, sourceID string, executionIndex int) string { + return sessionExecutionScopedID(prefix, sourceID, executionIndex, maxSessionExecutionAttemptIDLen) +} + +func sessionExecutionScopedID(prefix, sourceID string, executionIndex, maxLength int) string { + if executionIndex < 1 { + executionIndex = 1 + } + token := sessionExecutionIdentifierToken(sourceID) + digest := sessionExecutionIdentifierDigestHex(sourceID) + indexComponent := strconv.Itoa(executionIndex) + maxTokenLength := maxLength - len(prefix) - 1 - 2 - len(digest) - len(indexComponent) + if maxTokenLength < len("session") { + maxTokenLength = len("session") + } + if len(token) > maxTokenLength { + token = token[:maxTokenLength] + } + return fmt.Sprintf("%s_%s_%s_%s", prefix, token, digest, indexComponent) +} + +func sessionExecutionIdentifierDigestHex(value string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(value))) + return hex.EncodeToString(sum[:]) +} + +func sessionExecutionIdentifierToken(value string) string { + trimmed := strings.TrimSpace(strings.ToLower(value)) + if trimmed == "" { + return "session" + } + b := strings.Builder{} + b.Grow(len(trimmed)) + for i := 0; i < len(trimmed); i++ { + ch := trimmed[i] + if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' { + b.WriteByte(ch) + continue + } + b.WriteByte('_') + } + normalized := strings.Trim(b.String(), "_-") + if normalized == "" { + return "session" + } + if normalized[0] < 'a' || normalized[0] > 'z' { + return "s_" + normalized + } + return normalized +} diff --git a/internal/brokerperf/harness.go b/internal/brokerperf/harness.go new file mode 100644 index 00000000..cb44cd08 --- /dev/null +++ b/internal/brokerperf/harness.go @@ -0,0 +1,121 @@ +package brokerperf + +import ( + "fmt" + "os" + "strings" + + "github.com/runecode-ai/runecode/internal/brokerapi" + "github.com/runecode-ai/runecode/internal/perfcontracts" +) + +const CheckSchemaVersion = "runecode.performance.check.v1" + +type HarnessConfig struct { + Trials int + RepositoryRoot string +} + +type latencySpec struct { + metricID string + call func() error +} + +type watchSpec struct { + latencyMetricID string + payloadMetricID string + countMetricID string + call func() (any, error) +} + +func Run(cfg HarnessConfig) (perfcontracts.CheckOutput, error) { + repoRoot, err := brokerPerfResolveRepoRoot(cfg.RepositoryRoot) + if err != nil { + return perfcontracts.CheckOutput{}, err + } + trials := brokerPerfResolvedTrials(cfg.Trials) + measurements, err := brokerPerfCollectMeasurements(trials, repoRoot) + if err != nil { + return perfcontracts.CheckOutput{}, err + } + return perfcontracts.CheckOutput{SchemaVersion: CheckSchemaVersion, Measurements: measurements}, nil +} + +func brokerPerfResolveRepoRoot(explicit string) (string, error) { + repoRoot := strings.TrimSpace(explicit) + if repoRoot != "" { + return repoRoot, nil + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return cwd, nil +} + +func brokerPerfResolvedTrials(trials int) int { + if trials <= 0 { + return 30 + } + return trials +} + +func brokerPerfCollectMeasurements(trials int, repoRoot string) ([]perfcontracts.MeasurementRecord, error) { + measurements := make([]perfcontracts.MeasurementRecord, 0, 26) + if err := brokerPerfAppendUnary(&measurements, trials, repoRoot); err != nil { + return nil, err + } + if err := brokerPerfAppendWatches(&measurements, trials, repoRoot); err != nil { + return nil, err + } + if err := brokerPerfAppendMutations(&measurements, trials, repoRoot); err != nil { + return nil, err + } + if err := brokerPerfAppendAttachResume(&measurements, trials, repoRoot); err != nil { + return nil, err + } + return measurements, nil +} + +func brokerPerfAppendUnary(measurements *[]perfcontracts.MeasurementRecord, trials int, repoRoot string) error { + items, err := measureUnary(trials, repoRoot) + if err != nil { + return err + } + *measurements = append(*measurements, items...) + return nil +} + +func brokerPerfAppendWatches(measurements *[]perfcontracts.MeasurementRecord, trials int, repoRoot string) error { + items, err := measureWatches(trials, repoRoot) + if err != nil { + return err + } + *measurements = append(*measurements, items...) + return nil +} + +func brokerPerfAppendMutations(measurements *[]perfcontracts.MeasurementRecord, trials int, repoRoot string) error { + items, err := measureMutations(trials, repoRoot) + if err != nil { + return err + } + *measurements = append(*measurements, items...) + return nil +} + +func brokerPerfAppendAttachResume(measurements *[]perfcontracts.MeasurementRecord, trials int, repoRoot string) error { + items, err := measureAttachResume(trials, repoRoot) + if err != nil { + return err + } + *measurements = append(*measurements, items...) + return nil +} + +func unaryErr(errResp *brokerapi.ErrorResponse, label string) error { + if errResp == nil { + return nil + } + return fmt.Errorf("%s: %s", label, errResp.Error.Code) +} diff --git a/internal/brokerperf/harness_approval_fixture.go b/internal/brokerperf/harness_approval_fixture.go new file mode 100644 index 00000000..0337a601 --- /dev/null +++ b/internal/brokerperf/harness_approval_fixture.go @@ -0,0 +1,250 @@ +package brokerperf + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/brokerapi" + "github.com/runecode-ai/runecode/internal/policyengine" + "github.com/runecode-ai/runecode/internal/trustpolicy" + "github.com/runecode-ai/runecode/third_party/jsoncanonicalizer" +) + +func seedBackendPostureApprovalForResolve(service *brokerapi.Service) (brokerapi.ApprovalResolveRequest, error) { + return seedBackendPostureApprovalForResolveWithRunID(service, "run-backend") +} + +func seedBackendPostureApprovalForResolveWithRunID(service *brokerapi.Service, runID string) (brokerapi.ApprovalResolveRequest, error) { + targetInstanceID, targetBackend, actionHash, err := backendPostureApprovalFixtureInputs(service) + if err != nil { + return brokerapi.ApprovalResolveRequest{}, err + } + requestEnv, approvalID, verifierRecord, privateKey, err := backendPostureApprovalRequestEnvelope(targetInstanceID, targetBackend, actionHash) + if err != nil { + return brokerapi.ApprovalResolveRequest{}, err + } + decisionEnv, err := backendPostureApprovalDecisionEnvelope(approvalID, verifierRecord, privateKey) + if err != nil { + return brokerapi.ApprovalResolveRequest{}, err + } + if err := putTrustedVerifierRecordForService(service, verifierRecord); err != nil { + return brokerapi.ApprovalResolveRequest{}, err + } + policyHash, err := persistBackendPostureApprovalFixture(service, targetInstanceID, actionHash, approvalID, requestEnv, runID) + if err != nil { + return brokerapi.ApprovalResolveRequest{}, err + } + return brokerapi.ApprovalResolveRequest{ + SchemaID: "runecode.protocol.v0.ApprovalResolveRequest", + SchemaVersion: "0.1.0", + RequestID: "perf-approval-resolve", + ApprovalID: approvalID, + BoundScope: brokerapi.ApprovalBoundScope{ + SchemaID: "runecode.protocol.v0.ApprovalBoundScope", + SchemaVersion: "0.1.0", + WorkspaceID: "workspace-local", + InstanceID: targetInstanceID, + RunID: strings.TrimSpace(runID), + ActionKind: policyengine.ActionKindBackendPosture, + PolicyDecisionHash: policyHash, + }, + ResolutionDetails: brokerapi.ApprovalResolveDetails{SchemaID: "runecode.protocol.v0.ApprovalResolveDetails", SchemaVersion: "0.1.0", BackendPostureSelection: &brokerapi.ApprovalResolveBackendPostureSelectionDetail{SchemaID: "runecode.protocol.v0.ApprovalResolveBackendPostureSelectionDetail", SchemaVersion: "0.1.0", TargetInstanceID: targetInstanceID, TargetBackendKind: targetBackend}}, + SignedApprovalRequest: requestEnv, + SignedApprovalDecision: decisionEnv, + }, nil +} + +func backendPostureApprovalFixtureInputs(service *brokerapi.Service) (string, string, string, error) { + postureResp, errResp := service.HandleBackendPostureGet(context.Background(), brokerapi.BackendPostureGetRequest{SchemaID: "runecode.protocol.v0.BackendPostureGetRequest", SchemaVersion: "0.1.0", RequestID: "seed-posture"}, brokerapi.RequestContext{}) + if errResp != nil { + return "", "", "", fmt.Errorf("backend_posture_get: %s", errResp.Error.Code) + } + targetInstanceID := postureResp.Posture.InstanceID + targetBackend := "container" + actionHash, err := policyengine.CanonicalActionRequestHash(policyengine.NewBackendPostureChangeAction(policyengine.BackendPostureChangeActionInput{ + ActionEnvelope: policyengine.ActionEnvelope{CapabilityID: "cap_backend", Actor: policyengine.ActionActor{ActorKind: "daemon", RoleFamily: "workspace", RoleKind: "workspace-edit"}}, + RunID: "instance-control:" + targetInstanceID, + TargetInstanceID: targetInstanceID, + TargetBackendKind: targetBackend, + SelectionMode: "explicit_selection", + ChangeKind: "select_backend", + AssuranceChangeKind: "reduce_assurance", + OptInKind: "exact_action_approval", + ReducedAssuranceAcknowledged: true, + Reason: "operator_requested_reduced_assurance_backend_opt_in", + })) + if err != nil { + return "", "", "", err + } + return targetInstanceID, targetBackend, actionHash, nil +} + +func backendPostureApprovalRequestEnvelope(targetInstanceID, targetBackend, actionHash string) (trustpolicy.SignedObjectEnvelope, string, trustpolicy.VerifierRecord, ed25519.PrivateKey, error) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return trustpolicy.SignedObjectEnvelope{}, "", trustpolicy.VerifierRecord{}, nil, err + } + keyIDValue := backendPostureKeyIDValue(publicKey) + requestBytes, err := marshalBackendPostureRequestPayload(targetInstanceID, targetBackend, actionHash, keyIDValue) + if err != nil { + return trustpolicy.SignedObjectEnvelope{}, "", trustpolicy.VerifierRecord{}, nil, err + } + requestCanonical, err := jsoncanonicalizer.Transform(requestBytes) + if err != nil { + return trustpolicy.SignedObjectEnvelope{}, "", trustpolicy.VerifierRecord{}, nil, err + } + requestSig := ed25519.Sign(privateKey, requestCanonical) + approvalID := backendPostureApprovalID(requestCanonical) + verifier := backendPostureVerifierRecord(publicKey, keyIDValue) + return backendPostureRequestEnvelope(requestBytes, keyIDValue, requestSig), approvalID, verifier, privateKey, nil +} + +func backendPostureKeyIDValue(publicKey ed25519.PublicKey) string { + keyID := sha256.Sum256(publicKey) + return hex.EncodeToString(keyID[:]) +} + +func marshalBackendPostureRequestPayload(targetInstanceID, targetBackend, actionHash, keyIDValue string) ([]byte, error) { + payload := map[string]any{ + "schema_id": trustpolicy.ApprovalRequestSchemaID, + "schema_version": trustpolicy.ApprovalRequestSchemaVersion, + "approval_profile": "moderate", + "requester": map[string]any{"schema_id": "runecode.protocol.v0.PrincipalIdentity", "schema_version": "0.2.0", "actor_kind": "daemon", "principal_id": "broker", "instance_id": "broker-1"}, + "approval_trigger_code": "reduced_assurance_backend", + "manifest_hash": map[string]any{"hash_alg": "sha256", "hash": strings.Repeat("1", 64)}, + "action_request_hash": map[string]any{"hash_alg": "sha256", "hash": strings.TrimPrefix(actionHash, "sha256:")}, + "relevant_artifact_hashes": []any{}, + "details_schema_id": "runecode.protocol.details.policy.required_approval.reduced_assurance_backend.v0", + "details": backendPostureRequestDetails(targetInstanceID, targetBackend), + "approval_assurance_level": "reauthenticated", + "presence_mode": "hardware_touch", + "requested_at": time.Now().UTC().Add(-time.Minute).Format(time.RFC3339), + "expires_at": time.Now().UTC().Add(30 * time.Minute).Format(time.RFC3339), + "staleness_posture": "invalidate_on_bound_input_change", + "changes_if_approved": "Reduced-assurance backend posture change may be applied.", + "signatures": []any{map[string]any{"alg": "ed25519", "key_id": trustpolicy.KeyIDProfile, "key_id_value": keyIDValue, "signature": base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize))}}, + } + return json.Marshal(payload) +} + +func backendPostureRequestDetails(targetInstanceID, targetBackend string) map[string]any { + return map[string]any{ + "target_instance_id": targetInstanceID, + "target_backend_kind": targetBackend, + "selection_mode": "explicit_selection", + "change_kind": "select_backend", + "requested_posture": "container_mode_explicit_opt_in", + "assurance_change_kind": "reduce_assurance", + "opt_in_kind": "exact_action_approval", + "reduced_assurance_acknowledged": true, + "approval_binding_posture": "exact_action", + } +} + +func backendPostureApprovalID(requestCanonical []byte) string { + requestHash := sha256.Sum256(requestCanonical) + return "sha256:" + hex.EncodeToString(requestHash[:]) +} + +func backendPostureVerifierRecord(publicKey ed25519.PublicKey, keyIDValue string) trustpolicy.VerifierRecord { + return trustpolicy.VerifierRecord{SchemaID: trustpolicy.VerifierSchemaID, SchemaVersion: trustpolicy.VerifierSchemaVersion, KeyID: trustpolicy.KeyIDProfile, KeyIDValue: keyIDValue, Alg: "ed25519", PublicKey: trustpolicy.PublicKey{Encoding: "base64", Value: base64.StdEncoding.EncodeToString(publicKey)}, LogicalPurpose: "approval_authority", LogicalScope: "user", OwnerPrincipal: trustpolicy.PrincipalIdentity{SchemaID: "runecode.protocol.v0.PrincipalIdentity", SchemaVersion: "0.2.0", ActorKind: "user", PrincipalID: "human", InstanceID: "approval-session"}, KeyProtectionPosture: "hardware_backed", IdentityBindingPosture: "attested", PresenceMode: "hardware_touch", CreatedAt: "2026-03-13T12:00:00Z", Status: "active"} +} + +func backendPostureRequestEnvelope(requestBytes []byte, keyIDValue string, sig []byte) trustpolicy.SignedObjectEnvelope { + return trustpolicy.SignedObjectEnvelope{SchemaID: trustpolicy.EnvelopeSchemaID, SchemaVersion: trustpolicy.EnvelopeSchemaVersion, PayloadSchemaID: trustpolicy.ApprovalRequestSchemaID, PayloadSchemaVersion: trustpolicy.ApprovalRequestSchemaVersion, Payload: requestBytes, SignatureInput: trustpolicy.SignatureInputProfile, Signature: trustpolicy.SignatureBlock{Alg: "ed25519", KeyID: trustpolicy.KeyIDProfile, KeyIDValue: keyIDValue, Signature: base64.StdEncoding.EncodeToString(sig)}} +} + +func backendPostureApprovalDecisionEnvelope(approvalID string, verifier trustpolicy.VerifierRecord, privateKey ed25519.PrivateKey) (trustpolicy.SignedObjectEnvelope, error) { + decisionBytes, err := marshalBackendPostureDecisionPayload(approvalID, verifier) + if err != nil { + return trustpolicy.SignedObjectEnvelope{}, err + } + decisionCanonical, err := jsoncanonicalizer.Transform(decisionBytes) + if err != nil { + return trustpolicy.SignedObjectEnvelope{}, err + } + decisionSig := ed25519.Sign(privateKey, decisionCanonical) + return trustpolicy.SignedObjectEnvelope{SchemaID: trustpolicy.EnvelopeSchemaID, SchemaVersion: trustpolicy.EnvelopeSchemaVersion, PayloadSchemaID: trustpolicy.ApprovalDecisionSchemaID, PayloadSchemaVersion: trustpolicy.ApprovalDecisionSchemaVersion, Payload: decisionBytes, SignatureInput: trustpolicy.SignatureInputProfile, Signature: trustpolicy.SignatureBlock{Alg: "ed25519", KeyID: trustpolicy.KeyIDProfile, KeyIDValue: verifier.KeyIDValue, Signature: base64.StdEncoding.EncodeToString(decisionSig)}}, nil +} + +func marshalBackendPostureDecisionPayload(approvalID string, verifier trustpolicy.VerifierRecord) ([]byte, error) { + decisionPayload := map[string]any{"schema_id": trustpolicy.ApprovalDecisionSchemaID, "schema_version": trustpolicy.ApprovalDecisionSchemaVersion, "approval_request_hash": map[string]any{"hash_alg": "sha256", "hash": strings.TrimPrefix(approvalID, "sha256:")}, "approver": map[string]any{"schema_id": "runecode.protocol.v0.PrincipalIdentity", "schema_version": "0.2.0", "actor_kind": "user", "principal_id": "human", "instance_id": "approval-session"}, "decision_outcome": "approve", "approval_assurance_level": "reauthenticated", "presence_mode": "hardware_touch", "key_protection_posture": "hardware_backed", "identity_binding_posture": "attested", "approval_assertion_hash": map[string]any{"hash_alg": "sha256", "hash": strings.Repeat("f", 64)}, "decided_at": time.Now().UTC().Format(time.RFC3339), "consumption_posture": "single_use", "signatures": []any{map[string]any{"alg": "ed25519", "key_id": trustpolicy.KeyIDProfile, "key_id_value": verifier.KeyIDValue, "signature": "c2ln"}}} + return json.Marshal(decisionPayload) +} + +func persistBackendPostureApprovalFixture(service *brokerapi.Service, targetInstanceID, actionHash, approvalID string, requestEnv trustpolicy.SignedObjectEnvelope, runID string) (string, error) { + runID = strings.TrimSpace(runID) + decisionRunID := runID + if decisionRunID == "" { + decisionRunID = "run-backend" + } + scope := map[string]any{ + "schema_id": "runecode.protocol.v0.ApprovalBoundScope", + "schema_version": "0.1.0", + "workspace_id": "workspace-local", + "instance_id": targetInstanceID, + "action_kind": policyengine.ActionKindBackendPosture, + } + if runID != "" { + scope["run_id"] = runID + } + policyDecision := policyengine.PolicyDecision{SchemaID: "runecode.protocol.v0.PolicyDecision", SchemaVersion: "0.3.0", DecisionOutcome: policyengine.DecisionRequireHumanApproval, PolicyReasonCode: "approval_required", ManifestHash: "sha256:" + strings.Repeat("1", 64), ActionRequestHash: actionHash, PolicyInputHashes: []string{"sha256:" + strings.Repeat("4", 64)}, DetailsSchemaID: "runecode.protocol.details.policy.evaluation.v0", Details: map[string]any{"precedence": "approval_profile_moderate"}, RequiredApprovalSchemaID: "runecode.protocol.details.policy.required_approval.reduced_assurance_backend.v0", RequiredApproval: map[string]any{"approval_trigger_code": "reduced_assurance_backend", "approval_assurance_level": "reauthenticated", "presence_mode": "hardware_touch", "scope": scope, "changes_if_approved": "Reduced-assurance backend posture change may be applied.", "approval_ttl_seconds": 1800}} + if err := service.RecordPolicyDecision(decisionRunID, "", policyDecision); err != nil { + return "", err + } + var policyHash string + if decisionRunID == "" { + return "", fmt.Errorf("backend posture approval fixture decision run_id is required") + } else { + refs := service.PolicyDecisionRefsForRun(decisionRunID) + if len(refs) == 0 { + return "", fmt.Errorf("missing policy decision refs") + } + policyHash = refs[len(refs)-1] + } + if err := recordPendingApproval(service, runID, targetInstanceID, actionHash, approvalID, policyHash, requestEnv); err != nil { + return "", err + } + return policyHash, nil +} + +func recordPendingApproval(service *brokerapi.Service, runID, targetInstanceID, actionHash, approvalID, policyHash string, requestEnv trustpolicy.SignedObjectEnvelope) error { + expiresAt := time.Now().UTC().Add(30 * time.Minute) + requestedAt := time.Now().UTC().Add(-time.Minute) + record := artifacts.ApprovalRecord{ApprovalID: approvalID, Status: "pending", WorkspaceID: "workspace-local", InstanceID: targetInstanceID, RunID: strings.TrimSpace(runID), ActionKind: policyengine.ActionKindBackendPosture, RequestedAt: requestedAt, ExpiresAt: &expiresAt, ApprovalTriggerCode: "reduced_assurance_backend", ChangesIfApproved: "Reduced-assurance backend posture change may be applied.", ApprovalAssuranceLevel: "reauthenticated", PresenceMode: "hardware_touch", ManifestHash: "sha256:" + strings.Repeat("1", 64), ActionRequestHash: actionHash, PolicyDecisionHash: policyHash, RequestDigest: approvalID, RequestEnvelope: &requestEnv} + return service.RecordApproval(record) +} + +func putTrustedVerifierRecordForService(service *brokerapi.Service, record trustpolicy.VerifierRecord) error { + b, err := json.Marshal(record) + if err != nil { + return err + } + provenance := "sha256:" + strings.Repeat("1", 64) + ref, err := service.Put(artifacts.PutRequest{Payload: b, ContentType: "application/json", DataClass: artifacts.DataClassAuditVerificationReport, ProvenanceReceiptHash: provenance, CreatedByRole: "auditd", TrustedSource: true}) + if err != nil { + return err + } + details := map[string]interface{}{artifacts.TrustedContractImportKindDetailKey: artifacts.TrustedContractImportKindVerifierRecord, artifacts.TrustedContractImportArtifactDigestDetailKey: ref.Digest, artifacts.TrustedContractImportProvenanceDetailKey: provenance} + return service.AppendTrustedAuditEvent(artifacts.TrustedContractImportAuditEventType, "brokerapi", details) +} + +func serviceCurrentInstanceID(service *brokerapi.Service) string { + resp, errResp := service.HandleBackendPostureGet(context.Background(), brokerapi.BackendPostureGetRequest{SchemaID: "runecode.protocol.v0.BackendPostureGetRequest", SchemaVersion: "0.1.0", RequestID: "perf-instance-id"}, brokerapi.RequestContext{}) + if errResp != nil { + return "launcher-instance-1" + } + if strings.TrimSpace(resp.Posture.InstanceID) == "" { + return "launcher-instance-1" + } + return strings.TrimSpace(resp.Posture.InstanceID) +} diff --git a/internal/brokerperf/harness_measurements.go b/internal/brokerperf/harness_measurements.go new file mode 100644 index 00000000..a75f8e91 --- /dev/null +++ b/internal/brokerperf/harness_measurements.go @@ -0,0 +1,249 @@ +package brokerperf + +import ( + "context" + "fmt" + "time" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/brokerapi" + "github.com/runecode-ai/runecode/internal/perfcontracts" +) + +func measureUnary(trials int, repoRoot string) ([]perfcontracts.MeasurementRecord, error) { + service, cleanup, err := newSeededService(repoRoot) + if err != nil { + return nil, err + } + defer cleanup() + ctx := context.Background() + specs := unaryLatencySpecs(ctx, service) + return collectLatencyMeasurements(trials, specs) +} + +func unaryLatencySpecs(ctx context.Context, service *brokerapi.Service) []latencySpec { + return []latencySpec{ + {metricID: "metric.broker.unary.session_list.p95_ms", call: func() error { + _, errResp := service.HandleSessionList(ctx, brokerapi.SessionListRequest{SchemaID: "runecode.protocol.v0.SessionListRequest", SchemaVersion: "0.1.0", RequestID: "perf-session-list", Limit: 20}, brokerapi.RequestContext{}) + return unaryErr(errResp, "session_list") + }}, + {metricID: "metric.broker.unary.session_get.p95_ms", call: func() error { + _, errResp := service.HandleSessionGet(ctx, brokerapi.SessionGetRequest{SchemaID: "runecode.protocol.v0.SessionGetRequest", SchemaVersion: "0.1.0", RequestID: "perf-session-get", SessionID: "sess-broker-1"}, brokerapi.RequestContext{}) + return unaryErr(errResp, "session_get") + }}, + {metricID: "metric.broker.unary.run_list.p95_ms", call: func() error { + _, errResp := service.HandleRunList(ctx, brokerapi.RunListRequest{SchemaID: "runecode.protocol.v0.RunListRequest", SchemaVersion: "0.1.0", RequestID: "perf-run-list", Limit: 20}, brokerapi.RequestContext{}) + return unaryErr(errResp, "run_list") + }}, + {metricID: "metric.broker.unary.run_get.p95_ms", call: func() error { + _, errResp := service.HandleRunGet(ctx, brokerapi.RunGetRequest{SchemaID: "runecode.protocol.v0.RunGetRequest", SchemaVersion: "0.1.0", RequestID: "perf-run-get", RunID: "run-broker-1"}, brokerapi.RequestContext{}) + return unaryErr(errResp, "run_get") + }}, + {metricID: "metric.broker.unary.approval_list.p95_ms", call: func() error { + _, errResp := service.HandleApprovalList(ctx, brokerapi.ApprovalListRequest{SchemaID: "runecode.protocol.v0.ApprovalListRequest", SchemaVersion: "0.1.0", RequestID: "perf-approval-list", Limit: 20}, brokerapi.RequestContext{}) + return unaryErr(errResp, "approval_list") + }}, + {metricID: "metric.broker.unary.readiness_get.p95_ms", call: func() error { + _, errResp := service.HandleReadinessGet(ctx, brokerapi.ReadinessGetRequest{SchemaID: "runecode.protocol.v0.ReadinessGetRequest", SchemaVersion: "0.1.0", RequestID: "perf-readiness"}, brokerapi.RequestContext{}) + return unaryErr(errResp, "readiness_get") + }}, + {metricID: "metric.broker.unary.version_info_get.p95_ms", call: func() error { + _, errResp := service.HandleVersionInfoGet(ctx, brokerapi.VersionInfoGetRequest{SchemaID: "runecode.protocol.v0.VersionInfoGetRequest", SchemaVersion: "0.1.0", RequestID: "perf-version"}, brokerapi.RequestContext{}) + return unaryErr(errResp, "version_info_get") + }}, + {metricID: "metric.broker.unary.project_substrate_posture_get.p95_ms", call: func() error { + _, errResp := service.HandleProjectSubstratePostureGet(ctx, brokerapi.ProjectSubstratePostureGetRequest{SchemaID: "runecode.protocol.v0.ProjectSubstratePostureGetRequest", SchemaVersion: "0.1.0", RequestID: "perf-project-posture"}, brokerapi.RequestContext{}) + return unaryErr(errResp, "project_substrate_posture_get") + }}, + } +} + +func measureWatches(trials int, repoRoot string) ([]perfcontracts.MeasurementRecord, error) { + service, cleanup, err := newSeededService(repoRoot) + if err != nil { + return nil, err + } + defer cleanup() + ctx := context.Background() + specs := []watchSpec{ + {latencyMetricID: "metric.broker.watch.run.snapshot_follow.p95_ms", payloadMetricID: "metric.broker.watch.run.snapshot_follow.payload_bytes", countMetricID: "metric.broker.watch.run.snapshot_follow.event_count", call: func() (any, error) { return streamRunWatch(ctx, service) }}, + {latencyMetricID: "metric.broker.watch.approval.snapshot_follow.p95_ms", payloadMetricID: "metric.broker.watch.approval.snapshot_follow.payload_bytes", countMetricID: "metric.broker.watch.approval.snapshot_follow.event_count", call: func() (any, error) { return streamApprovalWatch(ctx, service) }}, + {latencyMetricID: "metric.broker.watch.session.snapshot_follow.p95_ms", payloadMetricID: "metric.broker.watch.session.snapshot_follow.payload_bytes", countMetricID: "metric.broker.watch.session.snapshot_follow.event_count", call: func() (any, error) { return streamSessionWatch(ctx, service) }}, + {latencyMetricID: "metric.broker.watch.turn_execution.snapshot_follow.p95_ms", payloadMetricID: "metric.broker.watch.turn_execution.snapshot_follow.payload_bytes", countMetricID: "metric.broker.watch.turn_execution.snapshot_follow.event_count", call: func() (any, error) { return streamTurnExecutionWatch(ctx, service) }}, + } + return collectWatchMeasurements(trials, specs) +} + +func measureMutations(trials int, repoRoot string) ([]perfcontracts.MeasurementRecord, error) { + ctx := context.Background() + latency := map[string][]float64{} + for i := 0; i < trials; i++ { + triggerDuration, err := measureSessionExecutionTriggerMutation(ctx, repoRoot) + if err != nil { + return nil, err + } + latency["metric.broker.mutation.session_execution_trigger.p95_ms"] = append(latency["metric.broker.mutation.session_execution_trigger.p95_ms"], triggerDuration) + + continueDuration, err := measureSessionExecutionContinueMutation(ctx, repoRoot) + if err != nil { + return nil, err + } + latency["metric.broker.mutation.session_execution_continue.p95_ms"] = append(latency["metric.broker.mutation.session_execution_continue.p95_ms"], continueDuration) + + approvalDuration, err := measureApprovalResolveMutation(ctx, repoRoot) + if err != nil { + return nil, err + } + latency["metric.broker.mutation.approval_resolve.p95_ms"] = append(latency["metric.broker.mutation.approval_resolve.p95_ms"], approvalDuration) + + postureDuration, err := measureBackendPostureChangeMutation(ctx, repoRoot) + if err != nil { + return nil, err + } + latency["metric.broker.mutation.backend_posture_change.p95_ms"] = append(latency["metric.broker.mutation.backend_posture_change.p95_ms"], postureDuration) + } + return p95Records(latency) +} + +func measureAttachResume(trials int, repoRoot string) ([]perfcontracts.MeasurementRecord, error) { + attachSamples := make([]float64, 0, trials) + resumeSamples := make([]float64, 0, trials) + ctx := context.Background() + for i := 0; i < trials; i++ { + attachDuration, resumeDuration, err := measureAttachResumeTrial(ctx, repoRoot) + if err != nil { + return nil, err + } + attachSamples = append(attachSamples, attachDuration) + resumeSamples = append(resumeSamples, resumeDuration) + } + attachP95, err := p95(attachSamples) + if err != nil { + return nil, err + } + resumeP95, err := p95(resumeSamples) + if err != nil { + return nil, err + } + return []perfcontracts.MeasurementRecord{ + {MetricID: "metric.broker.attach.local_control_plane.p95_ms", Value: attachP95, Unit: "ms"}, + {MetricID: "metric.broker.resume.local_control_plane.p95_ms", Value: resumeP95, Unit: "ms"}, + }, nil +} + +func measureAttachResumeTrial(ctx context.Context, repoRoot string) (float64, float64, error) { + service, cleanup, err := newSeededService(repoRoot) + if err != nil { + return 0, 0, err + } + defer cleanup() + attachDuration, err := timedProductLifecyclePostureGet(ctx, service, "perf-attach", "attach") + if err != nil { + return 0, 0, err + } + resumeDuration, err := timedProductLifecyclePostureGet(ctx, service, "perf-resume", "resume") + if err != nil { + return 0, 0, err + } + return attachDuration, resumeDuration, nil +} + +func timedProductLifecyclePostureGet(ctx context.Context, service *brokerapi.Service, requestID, label string) (float64, error) { + return timedCall(func() error { + _, errResp := service.HandleProductLifecyclePostureGet(ctx, brokerapi.ProductLifecyclePostureGetRequest{SchemaID: "runecode.protocol.v0.ProductLifecyclePostureGetRequest", SchemaVersion: "0.1.0", RequestID: requestID}, brokerapi.RequestContext{}) + if errResp != nil { + return fmt.Errorf("product_lifecycle_posture_get %s: %s", label, errResp.Error.Code) + } + return nil + }) +} + +func measureSessionExecutionTriggerMutation(ctx context.Context, repoRoot string) (float64, error) { + service, cleanup, err := newSeededService(repoRoot) + if err != nil { + return 0, err + } + defer cleanup() + return timedCall(func() error { + _, errResp := service.HandleSessionExecutionTrigger(ctx, brokerapi.SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "perf-trigger", SessionID: "sess-broker-1", TriggerSource: "autonomous_background", RequestedOperation: "start", WorkflowRouting: &brokerapi.SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: "change_draft"}, AutonomyPosture: "operator_guided"}, brokerapi.RequestContext{}) + if errResp != nil { + return fmt.Errorf("session_execution_trigger: %s", errResp.Error.Code) + } + return nil + }) +} + +func measureSessionExecutionContinueMutation(ctx context.Context, repoRoot string) (float64, error) { + service, cleanup, err := newSeededService(repoRoot) + if err != nil { + return 0, err + } + defer cleanup() + startResp, errResp := service.HandleSessionExecutionTrigger(ctx, brokerapi.SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "perf-continue-start", SessionID: "sess-broker-1", TriggerSource: "autonomous_background", RequestedOperation: "start", WorkflowRouting: &brokerapi.SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: "change_draft"}, AutonomyPosture: "operator_guided"}, brokerapi.RequestContext{}) + if errResp != nil { + return 0, fmt.Errorf("continue start seed: %s", errResp.Error.Code) + } + if _, err := service.UpdateSessionTurnExecution(artifacts.SessionTurnExecutionUpdateRequest{SessionID: "sess-broker-1", TurnID: startResp.TurnID, ExecutionState: "blocked", WaitKind: "project_blocked", WaitState: "waiting_project_blocked", BlockedReasonCode: "project_substrate_posture_blocked", OccurredAt: time.Now().UTC()}); err != nil { + return 0, fmt.Errorf("continue blocked seed: %w", err) + } + return timedCall(func() error { + _, errResp = service.HandleSessionExecutionTrigger(ctx, brokerapi.SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "perf-continue", SessionID: "sess-broker-1", TurnID: startResp.TurnID, TriggerSource: "resume_follow_up", RequestedOperation: "continue", WorkflowRouting: &brokerapi.SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: "change_draft"}}, brokerapi.RequestContext{}) + if errResp != nil { + return fmt.Errorf("session_execution_continue: %s", errResp.Error.Code) + } + return nil + }) +} + +func measureApprovalResolveMutation(ctx context.Context, repoRoot string) (float64, error) { + service, cleanup, err := newSeededService(repoRoot) + if err != nil { + return 0, err + } + defer cleanup() + resolveReq, err := seedBackendPostureApprovalForResolveWithRunID(service, "") + if err != nil { + return 0, err + } + return timedCall(func() error { + _, errResp := service.HandleApprovalResolve(ctx, resolveReq, brokerapi.RequestContext{}) + if errResp != nil { + return fmt.Errorf("approval_resolve: %s: %s", errResp.Error.Code, errResp.Error.Message) + } + return nil + }) +} + +func measureBackendPostureChangeMutation(ctx context.Context, repoRoot string) (float64, error) { + service, cleanup, err := newSeededService(repoRoot) + if err != nil { + return 0, err + } + defer cleanup() + if err := seedBackendPosturePolicyContext(service); err != nil { + return 0, fmt.Errorf("backend_posture_change context: %w", err) + } + targetInstanceID := serviceCurrentInstanceID(service) + if targetInstanceID == "" { + return 0, fmt.Errorf("backend_posture_change target instance missing") + } + return timedCall(func() error { + _, errResp := service.HandleBackendPostureChange(ctx, brokerapi.BackendPostureChangeRequest{ + SchemaID: "runecode.protocol.v0.BackendPostureChangeRequest", + SchemaVersion: "0.1.0", + RequestID: "perf-backend-posture-change", + TargetInstanceID: targetInstanceID, + TargetBackendKind: "container", + SelectionMode: "explicit_selection", + ChangeKind: "select_backend", + AssuranceChangeKind: "reduce_assurance", + OptInKind: "exact_action_approval", + ReducedAssuranceAcknowledged: true, + Reason: "operator_requested_reduced_assurance_backend_opt_in", + }, brokerapi.RequestContext{}) + if errResp != nil { + return fmt.Errorf("backend_posture_change: %s: %s", errResp.Error.Code, errResp.Error.Message) + } + return nil + }) +} diff --git a/internal/brokerperf/harness_policy_context_fixture.go b/internal/brokerperf/harness_policy_context_fixture.go new file mode 100644 index 00000000..86aa19df --- /dev/null +++ b/internal/brokerperf/harness_policy_context_fixture.go @@ -0,0 +1,166 @@ +package brokerperf + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/brokerapi" + "github.com/runecode-ai/runecode/internal/trustpolicy" + "github.com/runecode-ai/runecode/third_party/jsoncanonicalizer" +) + +func seedBackendPosturePolicyContext(service *brokerapi.Service) error { + targetInstanceID := serviceCurrentInstanceID(service) + if strings.TrimSpace(targetInstanceID) == "" { + return fmt.Errorf("backend posture policy context target instance missing") + } + controlRunID := "instance-control:" + targetInstanceID + verifier, privateKey, err := backendPosturePolicyContextVerifier() + if err != nil { + return err + } + if err := putTrustedVerifierRecordForService(service, verifier); err != nil { + return err + } + allowlistDigest, err := persistBackendPosturePolicyAllowlist(service, controlRunID) + if err != nil { + return err + } + if err := persistBackendPostureRoleManifest(service, controlRunID, allowlistDigest, verifier, privateKey); err != nil { + return err + } + return persistBackendPostureRunCapability(service, controlRunID, allowlistDigest, verifier, privateKey) +} + +func persistBackendPosturePolicyAllowlist(service *brokerapi.Service, controlRunID string) (string, error) { + allowlistPayload, err := json.Marshal(backendPosturePolicyAllowlistPayload()) + if err != nil { + return "", err + } + return putTrustedPolicyArtifactForService(service, controlRunID, artifacts.TrustedContractImportKindPolicyAllowlist, allowlistPayload) +} + +func persistBackendPostureRoleManifest(service *brokerapi.Service, controlRunID, allowlistDigest string, verifier trustpolicy.VerifierRecord, privateKey ed25519.PrivateKey) error { + payload, err := signedTrustedContextPayload(map[string]any{ + "schema_id": "runecode.protocol.v0.RoleManifest", + "schema_version": "0.2.0", + "principal": backendPostureSignedContextPrincipal(controlRunID), + "role_family": "workspace", + "role_kind": "workspace-edit", + "approval_profile": "moderate", + "capability_opt_ins": []any{"cap_backend"}, + "allowlist_refs": []any{digestObjectFromIdentity(allowlistDigest)}, + }, verifier, privateKey) + if err != nil { + return err + } + _, err = putTrustedPolicyArtifactForService(service, controlRunID, artifacts.TrustedContractImportKindRoleManifest, payload) + return err +} + +func persistBackendPostureRunCapability(service *brokerapi.Service, controlRunID, allowlistDigest string, verifier trustpolicy.VerifierRecord, privateKey ed25519.PrivateKey) error { + payload, err := signedTrustedContextPayload(map[string]any{ + "schema_id": "runecode.protocol.v0.CapabilityManifest", + "schema_version": "0.2.0", + "principal": backendPostureSignedContextPrincipal(controlRunID), + "manifest_scope": "run", + "run_id": controlRunID, + "approval_profile": "moderate", + "capability_opt_ins": []any{"cap_backend"}, + "allowlist_refs": []any{digestObjectFromIdentity(allowlistDigest)}, + }, verifier, privateKey) + if err != nil { + return err + } + _, err = putTrustedPolicyArtifactForService(service, controlRunID, artifacts.TrustedContractImportKindRunCapability, payload) + return err +} + +func backendPosturePolicyContextVerifier() (trustpolicy.VerifierRecord, ed25519.PrivateKey, error) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return trustpolicy.VerifierRecord{}, nil, err + } + keyIDValue := backendPostureKeyIDValue(publicKey) + return trustpolicy.VerifierRecord{SchemaID: trustpolicy.VerifierSchemaID, SchemaVersion: trustpolicy.VerifierSchemaVersion, KeyID: trustpolicy.KeyIDProfile, KeyIDValue: keyIDValue, Alg: "ed25519", PublicKey: trustpolicy.PublicKey{Encoding: "base64", Value: base64.StdEncoding.EncodeToString(publicKey)}, LogicalPurpose: "isolate_session_identity", LogicalScope: "session", OwnerPrincipal: trustpolicy.PrincipalIdentity{SchemaID: "runecode.protocol.v0.PrincipalIdentity", SchemaVersion: "0.2.0", ActorKind: "daemon", PrincipalID: "brokerapi", InstanceID: "brokerapi-1"}, KeyProtectionPosture: "os_keystore", IdentityBindingPosture: "attested", PresenceMode: "os_confirmation", CreatedAt: "2026-03-13T12:00:00Z", Status: "active"}, privateKey, nil +} + +func backendPosturePolicyAllowlistPayload() map[string]any { + return map[string]any{ + "schema_id": "runecode.protocol.v0.PolicyAllowlist", + "schema_version": "0.1.0", + "allowlist_kind": "gateway_scope_rule", + "entry_schema_id": "runecode.protocol.v0.GatewayScopeRule", + "entries": []any{map[string]any{ + "schema_id": "runecode.protocol.v0.GatewayScopeRule", + "schema_version": "0.1.0", + "scope_kind": "gateway_destination", + "entry_id": "model_default", + "gateway_role_kind": "model-gateway", + "destination": backendPostureGatewayDestinationDescriptor(), + "permitted_operations": []any{"invoke_model"}, + "allowed_egress_data_classes": []any{"spec_text"}, + "redirect_posture": "allowlist_only", + "max_timeout_seconds": 120, + "max_response_bytes": 16 << 20, + }}, + } +} + +func backendPostureGatewayDestinationDescriptor() map[string]any { + return map[string]any{ + "schema_id": "runecode.protocol.v0.DestinationDescriptor", + "schema_version": "0.1.0", + "descriptor_kind": "model_endpoint", + "canonical_host": "model.example.com", + "tls_required": true, + "private_range_blocking": "enforced", + "dns_rebinding_protection": "enforced", + } +} + +func signedTrustedContextPayload(payload map[string]any, verifier trustpolicy.VerifierRecord, privateKey ed25519.PrivateKey) ([]byte, error) { + payload["signatures"] = []any{} + clone := map[string]any{} + for k, v := range payload { + clone[k] = v + } + delete(clone, "signatures") + raw, err := json.Marshal(clone) + if err != nil { + return nil, err + } + canonical, err := jsoncanonicalizer.Transform(raw) + if err != nil { + return nil, err + } + sig := ed25519.Sign(privateKey, canonical) + payload["signatures"] = []any{map[string]any{"alg": "ed25519", "key_id": verifier.KeyID, "key_id_value": verifier.KeyIDValue, "signature": base64.StdEncoding.EncodeToString(sig)}} + return json.Marshal(payload) +} + +func backendPostureSignedContextPrincipal(runID string) map[string]any { + return map[string]any{"schema_id": "runecode.protocol.v0.PrincipalIdentity", "schema_version": "0.2.0", "actor_kind": "role_instance", "principal_id": "brokerapi", "instance_id": "brokerapi-1", "role_family": "workspace", "role_kind": "workspace-edit", "run_id": runID} +} + +func digestObjectFromIdentity(identity string) map[string]any { + return map[string]any{"hash_alg": "sha256", "hash": strings.TrimPrefix(identity, "sha256:")} +} + +func putTrustedPolicyArtifactForService(service *brokerapi.Service, runID, kind string, payload []byte) (string, error) { + provenance := "sha256:" + strings.Repeat("1", 64) + ref, err := service.Put(artifacts.PutRequest{Payload: payload, ContentType: "application/json", DataClass: artifacts.DataClassAuditVerificationReport, ProvenanceReceiptHash: provenance, CreatedByRole: "broker", TrustedSource: true, RunID: strings.TrimSpace(runID)}) + if err != nil { + return "", err + } + details := map[string]interface{}{artifacts.TrustedContractImportKindDetailKey: kind, artifacts.TrustedContractImportArtifactDigestDetailKey: ref.Digest, artifacts.TrustedContractImportProvenanceDetailKey: provenance} + if err := service.AppendTrustedAuditEvent(artifacts.TrustedContractImportAuditEventType, "brokerapi", details); err != nil { + return "", err + } + return ref.Digest, nil +} diff --git a/internal/brokerperf/harness_runtime.go b/internal/brokerperf/harness_runtime.go new file mode 100644 index 00000000..9ce52ebd --- /dev/null +++ b/internal/brokerperf/harness_runtime.go @@ -0,0 +1,249 @@ +package brokerperf + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/brokerapi" + "github.com/runecode-ai/runecode/internal/launcherbackend" + "github.com/runecode-ai/runecode/internal/perfcontracts" + "github.com/runecode-ai/runecode/internal/policyengine" +) + +func newSeededService(repoRoot string) (*brokerapi.Service, func(), error) { + root, err := os.MkdirTemp("", "runecode-brokerperf-") + if err != nil { + return nil, nil, err + } + cleanup := func() { _ = os.RemoveAll(root) } + service, err := brokerapi.NewServiceWithConfig(root, filepath.Join(root, "audit-ledger"), brokerapi.APIConfig{RepositoryRoot: repoRoot}) + if err != nil { + cleanup() + return nil, nil, err + } + if err := seedServiceData(service); err != nil { + cleanup() + return nil, nil, err + } + return service, cleanup, nil +} + +func seedServiceData(service *brokerapi.Service) error { + if err := service.SetRunStatus("run-broker-1", "active"); err != nil { + return err + } + if err := service.RecordRuntimeFacts("run-broker-1", launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: launcherbackend.BackendLaunchReceipt{RunID: "run-broker-1", SessionID: "sess-broker-1"}}); err != nil { + return err + } + if err := seedBlockedTurn(service, "seed-trigger-1", "seed"); err != nil { + return err + } + if err := seedBlockedTurn(service, "seed-trigger-2", "seed follow-up"); err != nil { + return err + } + return service.RecordPolicyDecision("run-broker-1", "", seedPolicyDecision()) +} + +func seedBlockedTurn(service *brokerapi.Service, requestID, message string) error { + triggerResp, errResp := service.HandleSessionExecutionTrigger(context.Background(), brokerapi.SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: requestID, SessionID: "sess-broker-1", TriggerSource: "interactive_user", RequestedOperation: "start", WorkflowRouting: &brokerapi.SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: "change_draft"}, UserMessageContentText: message}, brokerapi.RequestContext{}) + if errResp != nil { + return fmt.Errorf("seed session_execution_trigger: %s", errResp.Error.Code) + } + _, _ = service.UpdateSessionTurnExecution(artifacts.SessionTurnExecutionUpdateRequest{SessionID: "sess-broker-1", TurnID: triggerResp.TurnID, ExecutionState: "blocked", WaitKind: "project_blocked", WaitState: "waiting_project_blocked", BlockedReasonCode: "project_substrate_posture_blocked", OccurredAt: time.Now().UTC()}) + return nil +} + +func seedPolicyDecision() policyengine.PolicyDecision { + return policyengine.PolicyDecision{ + SchemaID: "runecode.protocol.v0.PolicyDecision", + SchemaVersion: "0.3.0", + DecisionOutcome: policyengine.DecisionRequireHumanApproval, + PolicyReasonCode: "approval_required", + ManifestHash: "sha256:" + strings.Repeat("1", 64), + ActionRequestHash: "sha256:" + strings.Repeat("2", 64), + PolicyInputHashes: []string{"sha256:" + strings.Repeat("3", 64)}, + DetailsSchemaID: "runecode.protocol.details.policy.evaluation.v0", + Details: map[string]any{"precedence": "approval_profile_moderate"}, + RequiredApprovalSchemaID: "runecode.protocol.details.policy.required_approval.moderate.workspace_write.v0", + RequiredApproval: map[string]any{ + "approval_trigger_code": "excerpt_promotion", + "approval_assurance_level": "session_authenticated", + "presence_mode": "os_confirmation", + "scope": map[string]any{ + "schema_id": "runecode.protocol.v0.ApprovalBoundScope", + "schema_version": "0.1.0", + "workspace_id": "workspace-local", + "run_id": "run-broker-1", + "stage_id": "artifact_flow", + "step_id": "step-1", + "action_kind": "promotion", + }, + "changes_if_approved": "Promote reviewed file excerpts for downstream use.", + "approval_ttl_seconds": 1800, + }, + } +} + +func runWatchLatencyAndPayload(call func() (any, error)) (float64, float64, float64, error) { + started := time.Now() + result, err := call() + if err != nil { + return 0, 0, 0, err + } + bytes, count, err := payloadStats(result) + if err != nil { + return 0, 0, 0, err + } + return float64(time.Since(started).Milliseconds()), float64(bytes), float64(count), nil +} + +func collectLatencyMeasurements(trials int, specs []latencySpec) ([]perfcontracts.MeasurementRecord, error) { + latency := map[string][]float64{} + for i := 0; i < trials; i++ { + for _, spec := range specs { + duration, err := timedCall(spec.call) + if err != nil { + return nil, err + } + latency[spec.metricID] = append(latency[spec.metricID], duration) + } + } + return p95Records(latency) +} + +func collectWatchMeasurements(trials int, specs []watchSpec) ([]perfcontracts.MeasurementRecord, error) { + latency := map[string][]float64{} + watchPayload := map[string]float64{} + watchCounts := map[string]float64{} + for i := 0; i < trials; i++ { + for _, spec := range specs { + duration, bytes, count, err := runWatchLatencyAndPayload(spec.call) + if err != nil { + return nil, err + } + latency[spec.latencyMetricID] = append(latency[spec.latencyMetricID], duration) + watchPayload[spec.payloadMetricID] = maxFloat64(watchPayload[spec.payloadMetricID], bytes) + watchCounts[spec.countMetricID] = maxFloat64(watchCounts[spec.countMetricID], count) + } + } + measurements, err := p95Records(latency) + if err != nil { + return nil, err + } + return appendWatchStats(measurements, watchPayload, watchCounts), nil +} + +func appendWatchStats(measurements []perfcontracts.MeasurementRecord, watchPayload, watchCounts map[string]float64) []perfcontracts.MeasurementRecord { + for metricID, value := range watchPayload { + measurements = append(measurements, perfcontracts.MeasurementRecord{MetricID: metricID, Value: value, Unit: "bytes"}) + } + for metricID, value := range watchCounts { + measurements = append(measurements, perfcontracts.MeasurementRecord{MetricID: metricID, Value: value, Unit: "count"}) + } + return measurements +} + +func streamRunWatch(ctx context.Context, service *brokerapi.Service) (any, error) { + ack, errResp := service.HandleRunWatchRequest(ctx, brokerapi.RunWatchRequest{SchemaID: "runecode.protocol.v0.RunWatchRequest", SchemaVersion: "0.1.0", RequestID: "perf-run-watch", Follow: true, IncludeSnapshot: true}, brokerapi.RequestContext{}) + if errResp != nil { + return nil, fmt.Errorf("run_watch ack: %s", errResp.Error.Code) + } + return service.StreamRunWatchEvents(ack) +} + +func streamApprovalWatch(ctx context.Context, service *brokerapi.Service) (any, error) { + ack, errResp := service.HandleApprovalWatchRequest(ctx, brokerapi.ApprovalWatchRequest{SchemaID: "runecode.protocol.v0.ApprovalWatchRequest", SchemaVersion: "0.1.0", RequestID: "perf-approval-watch", Follow: true, IncludeSnapshot: true}, brokerapi.RequestContext{}) + if errResp != nil { + return nil, fmt.Errorf("approval_watch ack: %s", errResp.Error.Code) + } + return service.StreamApprovalWatchEvents(ack) +} + +func streamSessionWatch(ctx context.Context, service *brokerapi.Service) (any, error) { + ack, errResp := service.HandleSessionWatchRequest(ctx, brokerapi.SessionWatchRequest{SchemaID: "runecode.protocol.v0.SessionWatchRequest", SchemaVersion: "0.1.0", RequestID: "perf-session-watch", Follow: true, IncludeSnapshot: true}, brokerapi.RequestContext{}) + if errResp != nil { + return nil, fmt.Errorf("session_watch ack: %s", errResp.Error.Code) + } + return service.StreamSessionWatchEvents(ack) +} + +func streamTurnExecutionWatch(ctx context.Context, service *brokerapi.Service) (any, error) { + ack, errResp := service.HandleSessionTurnExecutionWatchRequest(ctx, brokerapi.SessionTurnExecutionWatchRequest{SchemaID: "runecode.protocol.v0.SessionTurnExecutionWatchRequest", SchemaVersion: "0.1.0", RequestID: "perf-turn-watch", Follow: true, IncludeSnapshot: true}, brokerapi.RequestContext{}) + if errResp != nil { + return nil, fmt.Errorf("session_turn_execution_watch ack: %s", errResp.Error.Code) + } + return service.StreamSessionTurnExecutionWatchEvents(ack) +} + +func timedCall(call func() error) (float64, error) { + started := time.Now() + if err := call(); err != nil { + return 0, err + } + return float64(time.Since(started).Milliseconds()), nil +} + +func payloadStats(value any) (int, int, error) { + blob, err := json.Marshal(value) + if err != nil { + return 0, 0, err + } + count := 1 + if values, ok := value.([]brokerapi.RunWatchEvent); ok { + count = len(values) + } else if values, ok := value.([]brokerapi.ApprovalWatchEvent); ok { + count = len(values) + } else if values, ok := value.([]brokerapi.SessionWatchEvent); ok { + count = len(values) + } else if values, ok := value.([]brokerapi.SessionTurnExecutionWatchEvent); ok { + count = len(values) + } + return len(blob), count, nil +} + +func p95Records(samplesByMetric map[string][]float64) ([]perfcontracts.MeasurementRecord, error) { + keys := make([]string, 0, len(samplesByMetric)) + for metricID := range samplesByMetric { + keys = append(keys, metricID) + } + sort.Strings(keys) + records := make([]perfcontracts.MeasurementRecord, 0, len(keys)) + for _, metricID := range keys { + value, err := p95(samplesByMetric[metricID]) + if err != nil { + return nil, fmt.Errorf("compute p95 for %s: %w", metricID, err) + } + records = append(records, perfcontracts.MeasurementRecord{MetricID: metricID, Value: value, Unit: "ms"}) + } + return records, nil +} + +func maxFloat64(a, b float64) float64 { + if b > a { + return b + } + return a +} + +func p95(samples []float64) (float64, error) { + if len(samples) == 0 { + return 0, fmt.Errorf("samples required") + } + vals := append([]float64(nil), samples...) + sort.Float64s(vals) + idx := int(float64(len(vals)-1) * 0.95) + if idx < 0 { + idx = 0 + } + if idx >= len(vals) { + idx = len(vals) - 1 + } + return vals[idx], nil +} diff --git a/internal/brokerperf/harness_test.go b/internal/brokerperf/harness_test.go new file mode 100644 index 00000000..53c1801f --- /dev/null +++ b/internal/brokerperf/harness_test.go @@ -0,0 +1,152 @@ +package brokerperf + +import ( + "context" + "path/filepath" + "runtime" + "testing" + + "github.com/runecode-ai/runecode/internal/brokerapi" + "github.com/runecode-ai/runecode/internal/perfcontracts" +) + +func TestRunDeterministicBrokerHarnessProducesPhase3Metrics(t *testing.T) { + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) + out, err := Run(HarnessConfig{Trials: 2, RepositoryRoot: repoRoot}) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + if out.SchemaVersion != CheckSchemaVersion { + t.Fatalf("schema_version = %q, want %q", out.SchemaVersion, CheckSchemaVersion) + } + if len(out.Measurements) == 0 { + t.Fatal("measurements empty") + } + assertMetricUnit(t, out.Measurements, "metric.broker.unary.session_list.p95_ms", "ms") + assertMetricUnit(t, out.Measurements, "metric.broker.watch.run.snapshot_follow.p95_ms", "ms") + assertMetricUnit(t, out.Measurements, "metric.broker.watch.run.snapshot_follow.payload_bytes", "bytes") + assertMetricUnit(t, out.Measurements, "metric.broker.watch.turn_execution.snapshot_follow.event_count", "count") + assertMetricValue(t, out.Measurements, "metric.broker.watch.run.snapshot_follow.event_count", 3) + assertMetricValue(t, out.Measurements, "metric.broker.watch.turn_execution.snapshot_follow.event_count", 3) + assertMetricUnit(t, out.Measurements, "metric.broker.mutation.session_execution_trigger.p95_ms", "ms") + assertMetricUnit(t, out.Measurements, "metric.broker.mutation.session_execution_continue.p95_ms", "ms") + assertMetricUnit(t, out.Measurements, "metric.broker.mutation.approval_resolve.p95_ms", "ms") + assertMetricUnit(t, out.Measurements, "metric.broker.mutation.backend_posture_change.p95_ms", "ms") + assertMetricUnit(t, out.Measurements, "metric.broker.attach.local_control_plane.p95_ms", "ms") + assertMetricUnit(t, out.Measurements, "metric.broker.resume.local_control_plane.p95_ms", "ms") +} + +func TestP95RecordsRejectsEmptySampleSet(t *testing.T) { + t.Parallel() + if _, err := p95Records(map[string][]float64{"metric.empty": nil}); err == nil { + t.Fatal("p95Records error = nil, want empty sample failure") + } +} + +func TestMeasureMutationHarnessUsesContractBoundaryScenarios(t *testing.T) { + t.Parallel() + + repoRoot := repositoryRootForHarnessTests(t) + ctx := context.Background() + triggerDuration, triggerErr := measureSessionExecutionTriggerMutation(ctx, repoRoot) + assertNonNegativeMutationDuration(t, "trigger", triggerDuration, triggerErr) + continueDuration, continueErr := measureSessionExecutionContinueMutation(ctx, repoRoot) + assertNonNegativeMutationDuration(t, "continue", continueDuration, continueErr) + assertBackendPostureMutationFixture(t, repoRoot, ctx) +} + +func assertNonNegativeMutationDuration(t *testing.T, label string, duration float64, err error) { + t.Helper() + if err != nil { + t.Fatalf("%s mutation returned error: %v", label, err) + } + if duration < 0 { + t.Fatalf("%s duration = %v, want non-negative", label, duration) + } +} + +func assertBackendPostureMutationFixture(t *testing.T, repoRoot string, ctx context.Context) { + t.Helper() + service, cleanup, err := newSeededService(repoRoot) + if err != nil { + t.Fatalf("newSeededService returned error: %v", err) + } + defer cleanup() + if err := seedBackendPosturePolicyContext(service); err != nil { + t.Fatalf("seedBackendPosturePolicyContext returned error: %v", err) + } + instanceID := serviceCurrentInstanceID(service) + if instanceID == "" { + t.Fatal("instanceID empty") + } + changeResp, errResp := service.HandleBackendPostureChange(ctx, brokerapi.BackendPostureChangeRequest{ + SchemaID: "runecode.protocol.v0.BackendPostureChangeRequest", + SchemaVersion: "0.1.0", + RequestID: "req-harness-backend-posture-change", + TargetInstanceID: instanceID, + TargetBackendKind: "container", + SelectionMode: "explicit_selection", + ChangeKind: "select_backend", + AssuranceChangeKind: "reduce_assurance", + OptInKind: "exact_action_approval", + ReducedAssuranceAcknowledged: true, + Reason: "operator_requested_reduced_assurance_backend_opt_in", + }, brokerapi.RequestContext{}) + if errResp != nil { + t.Fatalf("HandleBackendPostureChange returned error: %+v", errResp) + } + if changeResp.Outcome.Outcome != "approval_required" { + t.Fatalf("backend posture outcome = %q, want approval_required", changeResp.Outcome.Outcome) + } + assertBackendPostureResolveFixture(t, service) +} + +func assertBackendPostureResolveFixture(t *testing.T, service *brokerapi.Service) { + t.Helper() + resolveReq, err := seedBackendPostureApprovalForResolveWithRunID(service, "run-backend") + if err != nil { + t.Fatalf("seedBackendPostureApprovalForResolveWithRunID returned error: %v", err) + } + if resolveReq.BoundScope.RunID != "run-backend" { + t.Fatalf("resolve bound_scope.run_id = %q, want run-backend", resolveReq.BoundScope.RunID) + } +} + +func repositoryRootForHarnessTests(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) +} + +func assertMetricUnit(t *testing.T, measurements []perfcontracts.MeasurementRecord, metricID, unit string) { + t.Helper() + for _, m := range measurements { + if m.MetricID == metricID { + if m.Unit != unit { + t.Fatalf("metric %s unit = %q, want %q", metricID, m.Unit, unit) + } + return + } + } + t.Fatalf("metric %s missing", metricID) +} + +func assertMetricValue(t *testing.T, measurements []perfcontracts.MeasurementRecord, metricID string, value float64) { + t.Helper() + for _, m := range measurements { + if m.MetricID == metricID { + if m.Value != value { + t.Fatalf("metric %s value = %v, want %v", metricID, m.Value, value) + } + return + } + } + t.Fatalf("metric %s missing", metricID) +} diff --git a/internal/launcherbackend/contract_attestation_posture.go b/internal/launcherbackend/contract_attestation_posture.go index 2d6a9b5b..4aa233f6 100644 --- a/internal/launcherbackend/contract_attestation_posture.go +++ b/internal/launcherbackend/contract_attestation_posture.go @@ -18,19 +18,27 @@ func DeriveAttestationPosture(receipt BackendLaunchReceipt) (string, []string) { } func deriveRequiredAttestationPosture(receipt BackendLaunchReceipt) (string, []string) { - if receipt.AttestationVerificationResult == AttestationVerificationResultValid && receipt.AttestationReplayVerdict == AttestationReplayVerdictOriginal { - return AttestationPostureValid, nil - } reasons := attestationPostureReasonCodes(receipt) - if receipt.AttestationEvidenceSourceKind == AttestationSourceKindUnknown || receipt.AttestationMeasurementProfile == "" { + if !receiptHasAttestationEvidence(receipt) { return AttestationPostureUnavailable, append(reasons, "attestation_evidence_unavailable") } - if receipt.AttestationVerificationResult == AttestationVerificationResultUnknown { + if !receiptHasAttestationVerification(receipt) { return AttestationPostureUnavailable, append(reasons, "attestation_verification_unavailable") } + if receipt.AttestationVerificationResult == AttestationVerificationResultValid && receipt.AttestationReplayVerdict == AttestationReplayVerdictOriginal { + return AttestationPostureValid, nil + } return AttestationPostureInvalid, reasons } +func receiptHasAttestationEvidence(receipt BackendLaunchReceipt) bool { + return receipt.AttestationEvidenceSourceKind != AttestationSourceKindUnknown && receipt.AttestationMeasurementProfile != "" && receipt.AttestationEvidenceDigest != "" +} + +func receiptHasAttestationVerification(receipt BackendLaunchReceipt) bool { + return receipt.AttestationVerificationResult != AttestationVerificationResultUnknown && receipt.AttestationReplayVerdict != AttestationReplayVerdictUnknown && receipt.AttestationVerificationDigest != "" +} + func attestationPostureReasonCodes(receipt BackendLaunchReceipt) []string { reasons := sanitizedAttestationReasonCodes(receipt.AttestationVerificationReasonCodes) if receipt.AttestationReplayVerdict == AttestationReplayVerdictReplay { @@ -40,6 +48,19 @@ func attestationPostureReasonCodes(receipt BackendLaunchReceipt) []string { } func DeriveAttestationPostureFromEvidence(evidence RuntimeEvidenceSnapshot) (string, []string) { + if evidence.Attestation == nil && evidence.AttestationVerification == nil { + return DeriveAttestationPosture(BackendLaunchReceipt{ProvisioningPosture: evidence.Launch.ProvisioningPosture}) + } + if evidence.Attestation == nil { + reasons := []string{"attestation_evidence_unavailable"} + if evidence.AttestationVerification != nil { + reasons = append(reasons, sanitizedAttestationReasonCodes(evidence.AttestationVerification.ReasonCodes)...) + } + return AttestationPostureUnavailable, uniqueSortedStrings(reasons) + } + if evidence.AttestationVerification == nil { + return AttestationPostureUnavailable, []string{"attestation_verification_unavailable"} + } receipt := BackendLaunchReceipt{ ProvisioningPosture: evidence.Launch.ProvisioningPosture, AttestationEvidenceSourceKind: AttestationSourceKindUnknown, @@ -51,11 +72,13 @@ func DeriveAttestationPostureFromEvidence(evidence RuntimeEvidenceSnapshot) (str if evidence.Attestation != nil { receipt.AttestationEvidenceSourceKind = evidence.Attestation.AttestationSourceKind receipt.AttestationMeasurementProfile = evidence.Attestation.MeasurementProfile + receipt.AttestationEvidenceDigest = evidence.Attestation.EvidenceDigest } if evidence.AttestationVerification != nil { receipt.AttestationVerificationResult = evidence.AttestationVerification.VerificationResult receipt.AttestationVerificationReasonCodes = evidence.AttestationVerification.ReasonCodes receipt.AttestationReplayVerdict = evidence.AttestationVerification.ReplayVerdict + receipt.AttestationVerificationDigest = evidence.AttestationVerification.VerificationDigest } return DeriveAttestationPosture(receipt) } @@ -65,17 +88,21 @@ func sanitizedAttestationReasonCodes(reasonCodes []string) []string { return nil } allowed := map[string]struct{}{ - "attestation_replay_detected": {}, - "attestation_source_kind_invalid": {}, - "attestation_measurement_digest_invalid": {}, - "attestation_freshness_material_missing": {}, - "attestation_freshness_binding_missing": {}, - "attestation_freshness_stale": {}, - "attestation_evidence_required": {}, - "attestation_verification_required": {}, - "attestation_verification_not_valid": {}, - "attestation_evidence_unavailable": {}, - "attestation_verification_unavailable": {}, + "attestation_replay_detected": {}, + "attestation_source_kind_invalid": {}, + "attestation_identity_binding_invalid": {}, + "attestation_measurement_digest_invalid": {}, + "attestation_session_validation_required": {}, + "attestation_post_handshake_input_required": {}, + "attestation_runtime_evidence_required": {}, + "attestation_freshness_material_missing": {}, + "attestation_freshness_binding_missing": {}, + "attestation_freshness_stale": {}, + "attestation_evidence_required": {}, + "attestation_verification_required": {}, + "attestation_verification_not_valid": {}, + "attestation_evidence_unavailable": {}, + "attestation_verification_unavailable": {}, } sanitized := make([]string, 0, len(reasonCodes)) for _, reason := range reasonCodes { diff --git a/internal/launcherbackend/contract_attestation_posture_test.go b/internal/launcherbackend/contract_attestation_posture_test.go new file mode 100644 index 00000000..4f99f525 --- /dev/null +++ b/internal/launcherbackend/contract_attestation_posture_test.go @@ -0,0 +1,80 @@ +package launcherbackend + +import "testing" + +func TestDeriveAttestationPostureAttestedReceiptWithoutEvidenceDigestIsUnavailable(t *testing.T) { + receipt := BackendLaunchReceipt{ + ProvisioningPosture: ProvisioningPostureAttested, + AttestationEvidenceSourceKind: AttestationSourceKindTPMQuote, + AttestationMeasurementProfile: MeasurementProfileMicroVMBootV1, + AttestationVerificationResult: AttestationVerificationResultValid, + AttestationReplayVerdict: AttestationReplayVerdictOriginal, + } + + posture, reasons := DeriveAttestationPosture(receipt) + if posture != AttestationPostureUnavailable { + t.Fatalf("posture = %q, want %q", posture, AttestationPostureUnavailable) + } + if !containsAnyReasonCode(reasons, "attestation_evidence_unavailable") { + t.Fatalf("reasons = %#v, want attestation_evidence_unavailable", reasons) + } +} + +func TestDeriveAttestationPostureAttestedReceiptWithoutVerificationDigestIsUnavailable(t *testing.T) { + receipt := BackendLaunchReceipt{ + ProvisioningPosture: ProvisioningPostureAttested, + AttestationEvidenceSourceKind: AttestationSourceKindTPMQuote, + AttestationMeasurementProfile: MeasurementProfileMicroVMBootV1, + AttestationEvidenceDigest: testDigest("1"), + AttestationVerificationResult: AttestationVerificationResultValid, + AttestationReplayVerdict: AttestationReplayVerdictOriginal, + } + + posture, reasons := DeriveAttestationPosture(receipt) + if posture != AttestationPostureUnavailable { + t.Fatalf("posture = %q, want %q", posture, AttestationPostureUnavailable) + } + if !containsAnyReasonCode(reasons, "attestation_verification_unavailable") { + t.Fatalf("reasons = %#v, want attestation_verification_unavailable", reasons) + } +} + +func TestDeriveAttestationPostureAttestedReceiptWithEvidenceAndVerificationDigestIsValid(t *testing.T) { + receipt := BackendLaunchReceipt{ + ProvisioningPosture: ProvisioningPostureAttested, + AttestationEvidenceSourceKind: AttestationSourceKindTPMQuote, + AttestationMeasurementProfile: MeasurementProfileMicroVMBootV1, + AttestationEvidenceDigest: testDigest("1"), + AttestationVerificationResult: AttestationVerificationResultValid, + AttestationReplayVerdict: AttestationReplayVerdictOriginal, + AttestationVerificationDigest: testDigest("2"), + } + + posture, reasons := DeriveAttestationPosture(receipt) + if posture != AttestationPostureValid { + t.Fatalf("posture = %q, want %q", posture, AttestationPostureValid) + } + if len(reasons) != 0 { + t.Fatalf("reasons = %#v, want empty", reasons) + } +} + +func TestDeriveAttestationPostureFromEvidenceRequiresVerificationDigestForValid(t *testing.T) { + facts := attestationRuntimeFactsFixture() + evidence, _, err := SplitRuntimeFactsEvidenceAndLifecycle(facts) + if err != nil { + t.Fatalf("SplitRuntimeFactsEvidenceAndLifecycle returned error: %v", err) + } + if evidence.AttestationVerification == nil { + t.Fatal("attestation verification missing") + } + evidence.AttestationVerification.VerificationDigest = "" + + posture, reasons := DeriveAttestationPostureFromEvidence(evidence) + if posture != AttestationPostureUnavailable { + t.Fatalf("posture = %q, want %q", posture, AttestationPostureUnavailable) + } + if !containsAnyReasonCode(reasons, "attestation_verification_unavailable") { + t.Fatalf("reasons = %#v, want attestation_verification_unavailable", reasons) + } +} diff --git a/internal/launcherbackend/contract_image_receipt_types.go b/internal/launcherbackend/contract_image_receipt_types.go index 1ae4413f..7b2a9ad5 100644 --- a/internal/launcherbackend/contract_image_receipt_types.go +++ b/internal/launcherbackend/contract_image_receipt_types.go @@ -160,7 +160,8 @@ type BackendTerminalReport struct { } type RuntimeFactsSnapshot struct { - LaunchReceipt BackendLaunchReceipt `json:"launch_receipt"` - HardeningPosture AppliedHardeningPosture `json:"hardening_posture"` - TerminalReport *BackendTerminalReport `json:"terminal_report,omitempty"` + LaunchReceipt BackendLaunchReceipt `json:"launch_receipt"` + PostHandshakeAttestationInput *PostHandshakeRuntimeAttestationInput `json:"post_handshake_attestation_input,omitempty"` + HardeningPosture AppliedHardeningPosture `json:"hardening_posture"` + TerminalReport *BackendTerminalReport `json:"terminal_report,omitempty"` } diff --git a/internal/launcherbackend/contract_runtime_attestation_evidence.go b/internal/launcherbackend/contract_runtime_attestation_evidence.go index 1e9e9bf9..5d0ed839 100644 --- a/internal/launcherbackend/contract_runtime_attestation_evidence.go +++ b/internal/launcherbackend/contract_runtime_attestation_evidence.go @@ -3,163 +3,168 @@ package launcherbackend import "strings" const ( - attestationReasonCodeReplayDetected = "attestation_replay_detected" - attestationReasonCodeSourceKindInvalid = "attestation_source_kind_invalid" - attestationReasonCodeMeasurementDigestInvalid = "attestation_measurement_digest_invalid" - attestationReasonCodeFreshnessMaterialMissing = "attestation_freshness_material_missing" - attestationReasonCodeFreshnessBindingMissing = "attestation_freshness_binding_missing" - attestationReasonCodeFreshnessStale = "attestation_freshness_stale" - attestationReasonCodeEvidenceRequired = "attestation_evidence_required" - attestationReasonCodeVerificationRequired = "attestation_verification_required" - attestationReasonCodeVerificationNotValid = "attestation_verification_not_valid" + attestationReasonCodeReplayDetected = "attestation_replay_detected" + attestationReasonCodeSourceKindInvalid = "attestation_source_kind_invalid" + attestationReasonCodeIdentityBindingInvalid = "attestation_identity_binding_invalid" + attestationReasonCodeMeasurementDigestInvalid = "attestation_measurement_digest_invalid" + attestationReasonCodeSessionValidationRequired = "attestation_session_validation_required" + attestationReasonCodePostHandshakeInputRequired = "attestation_post_handshake_input_required" + attestationReasonCodeRuntimeEvidenceRequired = "attestation_runtime_evidence_required" + attestationReasonCodeFreshnessMaterialMissing = "attestation_freshness_material_missing" + attestationReasonCodeFreshnessBindingMissing = "attestation_freshness_binding_missing" + attestationReasonCodeFreshnessStale = "attestation_freshness_stale" + attestationReasonCodeEvidenceRequired = "attestation_evidence_required" + attestationReasonCodeVerificationRequired = "attestation_verification_required" + attestationReasonCodeVerificationNotValid = "attestation_verification_not_valid" + trustedRuntimeAttestationVerifierPolicyID = "runtime_asset_admission_identity" + trustedRuntimeAttestationRulesVersion = "trusted-runtime-v1" ) -func buildIsolateAttestationEvidence(receipt BackendLaunchReceipt, launch LaunchRuntimeEvidence) (*IsolateAttestationEvidence, *IsolateAttestationVerificationRecord, error) { - if !hasIsolateAttestationEvidence(receipt) { +func buildIsolateAttestationEvidence(receipt BackendLaunchReceipt, postHandshake *PostHandshakeRuntimeAttestationInput, launch LaunchRuntimeEvidence) (*IsolateAttestationEvidence, *IsolateAttestationVerificationRecord, error) { + attestationInput := derivePostHandshakeRuntimeAttestationInput(postHandshake) + if !hasIsolateAttestationEvidence(attestationInput) { return nil, nil, nil } - evidence := isolateAttestationEvidenceFromReceipt(receipt, launch) + evidence := isolateAttestationEvidenceFromPostHandshakeInput(*attestationInput, launch) digest, err := canonicalSHA256Digest(isolateAttestationEvidenceDigestInput(*evidence), "isolate attestation evidence") if err != nil { return nil, nil, err } evidence.EvidenceDigest = digest - verification, err := isolateAttestationVerificationFromReceipt(receipt, launch.EvidenceDigest, digest) + verification, err := isolateAttestationVerificationFromPostHandshakeInput(*attestationInput, launch.EvidenceDigest, digest) if err != nil { return nil, nil, err } return evidence, verification, nil } -func hasIsolateAttestationEvidence(receipt BackendLaunchReceipt) bool { - return receipt.RunID != "" && receipt.IsolateID != "" && receipt.SessionID != "" && - receipt.SessionNonce != "" && receipt.HandshakeTranscriptHash != "" && - receipt.IsolateSessionKeyIDValue != "" && receipt.RuntimeImageDescriptorDigest != "" && - receipt.RuntimeImageBootProfile != "" && receipt.AttestationEvidenceSourceKind != "" && - receipt.AttestationMeasurementProfile != "" +func withTrustedAttestationVerificationDefaults(input PostHandshakeRuntimeAttestationInput) PostHandshakeRuntimeAttestationInput { + if strings.TrimSpace(input.VerifierPolicyID) == "" { + input.VerifierPolicyID = trustedRuntimeAttestationVerifierPolicyID + } + if strings.TrimSpace(input.VerifierPolicyDigest) == "" { + input.VerifierPolicyDigest = trustedVerificationPolicyDigest(input) + } + if strings.TrimSpace(input.VerificationRulesProfileVersion) == "" { + input.VerificationRulesProfileVersion = trustedRuntimeAttestationRulesVersion + } + input.VerificationResult = normalizeAttestationVerificationResult(input.VerificationResult) + input.ReplayVerdict = normalizeAttestationReplayVerdict(input.ReplayVerdict) + return input +} + +func derivePostHandshakeRuntimeAttestationInput(postHandshake *PostHandshakeRuntimeAttestationInput) *PostHandshakeRuntimeAttestationInput { + return NormalizePostHandshakeRuntimeAttestationInput(postHandshake) } -func isolateAttestationEvidenceFromReceipt(receipt BackendLaunchReceipt, launch LaunchRuntimeEvidence) *IsolateAttestationEvidence { - return &IsolateAttestationEvidence{ - RunID: receipt.RunID, - IsolateID: receipt.IsolateID, - SessionID: receipt.SessionID, - SessionNonce: receipt.SessionNonce, - HandshakeTranscriptHash: receipt.HandshakeTranscriptHash, - IsolateSessionKeyIDValue: receipt.IsolateSessionKeyIDValue, - LaunchRuntimeEvidenceDigest: launch.EvidenceDigest, - RuntimeImageDescriptorDigest: receipt.RuntimeImageDescriptorDigest, - RuntimeImageBootProfile: receipt.RuntimeImageBootProfile, - BootComponentDigestByName: cloneStringMap(receipt.BootComponentDigestByName), - BootComponentDigests: uniqueSortedStrings(receipt.BootComponentDigests), - AttestationSourceKind: receipt.AttestationEvidenceSourceKind, - MeasurementProfile: receipt.AttestationMeasurementProfile, - FreshnessMaterial: uniqueSortedStrings(receipt.AttestationFreshnessMaterial), - FreshnessBindingClaims: uniqueSortedStrings(receipt.AttestationFreshnessBindingClaims), - EvidenceClaimsDigest: receipt.AttestationEvidenceClaimsDigest, +func applyAttestationFailClosedPolicy(receipt BackendLaunchReceipt, postHandshake *PostHandshakeRuntimeAttestationInput, evidence *RuntimeEvidenceSnapshot) { + if evidence == nil || !requiresAttestationVerification(receipt, postHandshake) { + return + } + if failed := attestationFailClosedVerification(receipt, postHandshake, evidence); failed != nil { + evidence.AttestationVerification = failed + return } + reasonCodes := attestationReasonCodesForEvidence(evidence) + if len(reasonCodes) == 0 { + promoteAttestedProvisioningPosture(evidence) + return + } + evidence.AttestationVerification.VerificationResult = AttestationVerificationResultInvalid + evidence.AttestationVerification.ReasonCodes = reasonCodes } -func isolateAttestationEvidenceDigestInput(evidence IsolateAttestationEvidence) isolateAttestationEvidenceDigestFields { - return isolateAttestationEvidenceDigestFields{ - RunID: evidence.RunID, - IsolateID: evidence.IsolateID, - SessionID: evidence.SessionID, - SessionNonce: evidence.SessionNonce, - HandshakeTranscriptHash: evidence.HandshakeTranscriptHash, - IsolateSessionKeyIDValue: evidence.IsolateSessionKeyIDValue, - LaunchRuntimeEvidenceDigest: evidence.LaunchRuntimeEvidenceDigest, - RuntimeImageDescriptorDigest: evidence.RuntimeImageDescriptorDigest, - RuntimeImageBootProfile: evidence.RuntimeImageBootProfile, - BootComponentDigestByName: cloneStringMap(evidence.BootComponentDigestByName), - BootComponentDigests: evidence.BootComponentDigests, - AttestationSourceKind: evidence.AttestationSourceKind, - MeasurementProfile: evidence.MeasurementProfile, - FreshnessMaterial: evidence.FreshnessMaterial, - FreshnessBindingClaims: evidence.FreshnessBindingClaims, - EvidenceClaimsDigest: evidence.EvidenceClaimsDigest, +func attestationFailClosedVerification(receipt BackendLaunchReceipt, postHandshake *PostHandshakeRuntimeAttestationInput, evidence *RuntimeEvidenceSnapshot) *IsolateAttestationVerificationRecord { + if !hasValidatedSessionForAttestation(receipt, evidence) { + return invalidAttestationVerificationForRequiredEvidence("", []string{attestationReasonCodeSessionValidationRequired}, AttestationReplayVerdictUnknown) + } + if verification := missingPostHandshakeVerification(postHandshake, evidence); verification != nil { + return verification } + if verification := missingRuntimeEvidenceVerification(postHandshake, evidence); verification != nil { + return verification + } + if verification := missingAttestationEvidenceVerification(evidence); verification != nil { + return verification + } + if evidence.AttestationVerification == nil { + return invalidAttestationVerificationForRequiredEvidence(evidence.Attestation.EvidenceDigest, []string{attestationReasonCodeVerificationRequired}, AttestationReplayVerdictUnknown) + } + return nil } -func isolateAttestationVerificationFromReceipt(receipt BackendLaunchReceipt, launchEvidenceDigest, attestationEvidenceDigest string) (*IsolateAttestationVerificationRecord, error) { - if strings.TrimSpace(receipt.AttestationVerifierPolicyID) == "" && strings.TrimSpace(receipt.AttestationVerifierPolicyDigest) == "" && strings.TrimSpace(receipt.AttestationVerificationResult) == "" { - return nil, nil +func missingPostHandshakeVerification(postHandshake *PostHandshakeRuntimeAttestationInput, evidence *RuntimeEvidenceSnapshot) *IsolateAttestationVerificationRecord { + if NormalizePostHandshakeRuntimeAttestationInput(postHandshake) != nil { + return nil } - replayIdentityDigest, err := canonicalSHA256Digest(isolateAttestationReplayIdentityInput(receipt, launchEvidenceDigest, attestationEvidenceDigest), "isolate attestation replay identity") - if err != nil { - return nil, err - } - verification := &IsolateAttestationVerificationRecord{ - AttestationEvidenceDigest: attestationEvidenceDigest, - ReplayIdentityDigest: replayIdentityDigest, - VerifierPolicyID: receipt.AttestationVerifierPolicyID, - VerifierPolicyDigest: receipt.AttestationVerifierPolicyDigest, - VerificationRulesProfileVersion: receipt.AttestationVerificationRulesVersion, - VerificationTimestamp: receipt.AttestationVerificationTimestamp, - VerificationResult: receipt.AttestationVerificationResult, - ReasonCodes: uniqueSortedStrings(receipt.AttestationVerificationReasonCodes), - ReplayVerdict: receipt.AttestationReplayVerdict, - DerivedMeasurementDigests: []string{receipt.AttestationEvidenceClaimsDigest}, - } - if verification.DerivedMeasurementDigests[0] == "" { - verification.DerivedMeasurementDigests = nil - } - digest, err := canonicalSHA256Digest(isolateAttestationVerificationDigestInput(*verification), "isolate attestation verification") - if err != nil { - return nil, err + return invalidAttestationVerificationForRequiredEvidence("", requiredEvidenceReasonCodes(attestationReasonCodePostHandshakeInputRequired, evidence), AttestationReplayVerdictUnknown) +} + +func missingRuntimeEvidenceVerification(postHandshake *PostHandshakeRuntimeAttestationInput, evidence *RuntimeEvidenceSnapshot) *IsolateAttestationVerificationRecord { + if postHandshake != nil && postHandshake.RuntimeEvidenceCollected { + return nil } - verification.VerificationDigest = digest - return verification, nil + return invalidAttestationVerificationForRequiredEvidence("", requiredEvidenceReasonCodes(attestationReasonCodeRuntimeEvidenceRequired, evidence), AttestationReplayVerdictUnknown) } -func isolateAttestationReplayIdentityInput(receipt BackendLaunchReceipt, launchEvidenceDigest, attestationEvidenceDigest string) isolateAttestationReplayIdentityFields { - return isolateAttestationReplayIdentityFields{ - RunID: receipt.RunID, - IsolateID: receipt.IsolateID, - SessionID: receipt.SessionID, - SessionNonce: receipt.SessionNonce, - HandshakeTranscriptHash: receipt.HandshakeTranscriptHash, - IsolateSessionKeyIDValue: receipt.IsolateSessionKeyIDValue, - LaunchEvidenceDigest: launchEvidenceDigest, - AttestationEvidenceDigest: attestationEvidenceDigest, - MeasurementProfile: receipt.AttestationMeasurementProfile, +func missingAttestationEvidenceVerification(evidence *RuntimeEvidenceSnapshot) *IsolateAttestationVerificationRecord { + if evidence != nil && evidence.Attestation != nil { + return nil } + return invalidAttestationVerificationForRequiredEvidence("", []string{attestationReasonCodeEvidenceRequired}, AttestationReplayVerdictUnknown) } -func isolateAttestationVerificationDigestInput(verification IsolateAttestationVerificationRecord) isolateAttestationVerificationRecordDigestFields { - return isolateAttestationVerificationRecordDigestFields{ - AttestationEvidenceDigest: verification.AttestationEvidenceDigest, - ReplayIdentityDigest: verification.ReplayIdentityDigest, - VerifierPolicyID: verification.VerifierPolicyID, - VerifierPolicyDigest: verification.VerifierPolicyDigest, - VerificationRulesProfileVersion: verification.VerificationRulesProfileVersion, - VerificationTimestamp: verification.VerificationTimestamp, - VerificationResult: verification.VerificationResult, - ReasonCodes: verification.ReasonCodes, - ReplayVerdict: verification.ReplayVerdict, - DerivedMeasurementDigests: verification.DerivedMeasurementDigests, +func requiredEvidenceReasonCodes(requiredReason string, evidence *RuntimeEvidenceSnapshot) []string { + reasonCodes := []string{requiredReason} + if evidence == nil || evidence.Attestation == nil { + reasonCodes = append(reasonCodes, attestationReasonCodeEvidenceRequired) } + return reasonCodes } -func applyAttestationFailClosedPolicy(receipt BackendLaunchReceipt, evidence *RuntimeEvidenceSnapshot) { - if evidence == nil || receipt.ProvisioningPosture != ProvisioningPostureAttested { - return +func requiresAttestationVerification(receipt BackendLaunchReceipt, postHandshake *PostHandshakeRuntimeAttestationInput) bool { + normalized := receipt.Normalized() + if normalized.ProvisioningPosture == ProvisioningPostureAttested { + return true } - if evidence.Attestation == nil { - evidence.AttestationVerification = invalidAttestationVerificationForRequiredEvidence("", []string{attestationReasonCodeEvidenceRequired}, AttestationReplayVerdictUnknown) - return + if NormalizePostHandshakeRuntimeAttestationInput(postHandshake) != nil { + return true } - if evidence.AttestationVerification == nil { - evidence.AttestationVerification = invalidAttestationVerificationForRequiredEvidence(evidence.Attestation.EvidenceDigest, []string{attestationReasonCodeVerificationRequired}, AttestationReplayVerdictUnknown) - return + if normalized.AttestationEvidenceSourceKind != AttestationSourceKindUnknown || normalized.AttestationMeasurementProfile != "" { + return true } - reasonCodes := attestationReasonCodesForEvidence(evidence) - if len(reasonCodes) == 0 { + if normalized.AttestationVerificationResult != AttestationVerificationResultUnknown { + return true + } + if strings.TrimSpace(normalized.AttestationVerifierPolicyID) != "" || strings.TrimSpace(normalized.AttestationVerifierPolicyDigest) != "" { + return true + } + return false +} + +func hasValidatedSessionForAttestation(receipt BackendLaunchReceipt, evidence *RuntimeEvidenceSnapshot) bool { + if evidence == nil || evidence.Session == nil { + return false + } + security := receipt.SessionSecurity + if security == nil { + return false + } + if !security.MutuallyAuthenticated || !security.Encrypted || !security.ProofOfPossessionVerified { + return false + } + return evidence.Session.LaunchContextDigest != "" && evidence.Session.HandshakeTranscriptHash != "" && evidence.Session.IsolateSessionKeyIDValue != "" +} + +func promoteAttestedProvisioningPosture(evidence *RuntimeEvidenceSnapshot) { + if evidence == nil { return } - evidence.AttestationVerification.VerificationResult = AttestationVerificationResultInvalid - evidence.AttestationVerification.ReasonCodes = reasonCodes + evidence.Launch.ProvisioningPosture = ProvisioningPostureAttested + if evidence.Session != nil { + evidence.Session.ProvisioningPosture = ProvisioningPostureAttested + } } func attestationReasonCodesForEvidence(evidence *RuntimeEvidenceSnapshot) []string { @@ -168,12 +173,18 @@ func attestationReasonCodesForEvidence(evidence *RuntimeEvidenceSnapshot) []stri if evidence.AttestationVerification.VerificationResult != AttestationVerificationResultValid { reasonCodes = append(reasonCodes, attestationReasonCodeVerificationNotValid) } + if evidence.AttestationVerification.ReplayVerdict != AttestationReplayVerdictOriginal { + reasonCodes = append(reasonCodes, attestationReasonCodeVerificationNotValid) + } if evidence.AttestationVerification.ReplayVerdict == AttestationReplayVerdictReplay { reasonCodes = append(reasonCodes, attestationReasonCodeReplayDetected) } if !measurementProfileAcceptsSourceKind(evidence.Attestation.MeasurementProfile, evidence.Attestation.AttestationSourceKind) { reasonCodes = append(reasonCodes, attestationReasonCodeSourceKindInvalid) } + if !attestationIdentityMatchesLaunchEvidence(evidence.Launch, evidence.Attestation) { + reasonCodes = append(reasonCodes, attestationReasonCodeIdentityBindingInvalid) + } if !attestationMeasurementIdentityMatchesEvidence(evidence.Attestation) { reasonCodes = append(reasonCodes, attestationReasonCodeMeasurementDigestInvalid) } @@ -198,48 +209,10 @@ func invalidAttestationVerificationForRequiredEvidence(attestationEvidenceDigest } } -func finalizeAttestationVerificationRecord(evidence *RuntimeEvidenceSnapshot) error { - if evidence == nil || evidence.AttestationVerification == nil { - return nil - } - verification := evidence.AttestationVerification - if verification.AttestationEvidenceDigest == "" && evidence.Attestation != nil { - verification.AttestationEvidenceDigest = evidence.Attestation.EvidenceDigest - } - if verification.ReplayIdentityDigest == "" && evidence.Attestation != nil { - replayIdentityDigest, err := canonicalSHA256Digest(isolateAttestationReplayIdentityFields{ - RunID: evidence.Attestation.RunID, - IsolateID: evidence.Attestation.IsolateID, - SessionID: evidence.Attestation.SessionID, - SessionNonce: evidence.Attestation.SessionNonce, - HandshakeTranscriptHash: evidence.Attestation.HandshakeTranscriptHash, - IsolateSessionKeyIDValue: evidence.Attestation.IsolateSessionKeyIDValue, - LaunchEvidenceDigest: evidence.Attestation.LaunchRuntimeEvidenceDigest, - AttestationEvidenceDigest: evidence.Attestation.EvidenceDigest, - MeasurementProfile: evidence.Attestation.MeasurementProfile, - }, "isolate attestation replay identity") - if err != nil { - return err - } - verification.ReplayIdentityDigest = replayIdentityDigest - } - return FinalizeIsolateAttestationVerificationRecord(verification) -} - -func FinalizeIsolateAttestationVerificationRecord(record *IsolateAttestationVerificationRecord) error { - if record == nil { +func ReconcileRuntimeEvidenceAttestation(receipt BackendLaunchReceipt, postHandshake *PostHandshakeRuntimeAttestationInput, evidence *RuntimeEvidenceSnapshot) error { + if evidence == nil { return nil } - record.ReasonCodes = uniqueSortedStrings(record.ReasonCodes) - record.DerivedMeasurementDigests = uniqueSortedStrings(record.DerivedMeasurementDigests) - return assignAttestationVerificationDigest(record) -} - -func assignAttestationVerificationDigest(record *IsolateAttestationVerificationRecord) error { - digest, err := canonicalSHA256Digest(isolateAttestationVerificationDigestInput(*record), "isolate attestation verification") - if err != nil { - return err - } - record.VerificationDigest = digest - return nil + applyAttestationFailClosedPolicy(receipt.Normalized(), NormalizePostHandshakeRuntimeAttestationInput(postHandshake), evidence) + return finalizeAttestationVerificationRecord(evidence) } diff --git a/internal/launcherbackend/contract_runtime_attestation_evidence_records.go b/internal/launcherbackend/contract_runtime_attestation_evidence_records.go new file mode 100644 index 00000000..49c9a07f --- /dev/null +++ b/internal/launcherbackend/contract_runtime_attestation_evidence_records.go @@ -0,0 +1,208 @@ +package launcherbackend + +import "strings" + +func hasIsolateAttestationEvidence(input *PostHandshakeRuntimeAttestationInput) bool { + if input == nil { + return false + } + return input.RunID != "" && input.IsolateID != "" && input.SessionID != "" && + input.RuntimeEvidenceCollected && input.SessionNonce != "" && input.LaunchContextDigest != "" && input.HandshakeTranscriptHash != "" && + input.IsolateSessionKeyIDValue != "" && input.RuntimeImageDescriptorDigest != "" && + input.RuntimeImageBootProfile != "" && input.AttestationSourceKind != "" && input.AttestationSourceKind != AttestationSourceKindUnknown && + input.MeasurementProfile != "" && input.MeasurementProfile != MeasurementProfileUnknown +} + +func isolateAttestationEvidenceFromPostHandshakeInput(input PostHandshakeRuntimeAttestationInput, launch LaunchRuntimeEvidence) *IsolateAttestationEvidence { + return &IsolateAttestationEvidence{ + RunID: input.RunID, + IsolateID: input.IsolateID, + SessionID: input.SessionID, + SessionNonce: input.SessionNonce, + LaunchContextDigest: input.LaunchContextDigest, + HandshakeTranscriptHash: input.HandshakeTranscriptHash, + IsolateSessionKeyIDValue: input.IsolateSessionKeyIDValue, + LaunchRuntimeEvidenceDigest: launch.EvidenceDigest, + RuntimeImageDescriptorDigest: input.RuntimeImageDescriptorDigest, + RuntimeImageBootProfile: input.RuntimeImageBootProfile, + BootComponentDigestByName: cloneStringMap(input.BootComponentDigestByName), + BootComponentDigests: uniqueSortedStrings(input.BootComponentDigests), + AttestationSourceKind: input.AttestationSourceKind, + MeasurementProfile: input.MeasurementProfile, + FreshnessMaterial: uniqueSortedStrings(input.FreshnessMaterial), + FreshnessBindingClaims: uniqueSortedStrings(input.FreshnessBindingClaims), + EvidenceClaimsDigest: input.EvidenceClaimsDigest, + } +} + +func isolateAttestationEvidenceDigestInput(evidence IsolateAttestationEvidence) isolateAttestationEvidenceDigestFields { + return isolateAttestationEvidenceDigestFields{ + RunID: evidence.RunID, + IsolateID: evidence.IsolateID, + SessionID: evidence.SessionID, + SessionNonce: evidence.SessionNonce, + LaunchContextDigest: evidence.LaunchContextDigest, + HandshakeTranscriptHash: evidence.HandshakeTranscriptHash, + IsolateSessionKeyIDValue: evidence.IsolateSessionKeyIDValue, + LaunchRuntimeEvidenceDigest: evidence.LaunchRuntimeEvidenceDigest, + RuntimeImageDescriptorDigest: evidence.RuntimeImageDescriptorDigest, + RuntimeImageBootProfile: evidence.RuntimeImageBootProfile, + BootComponentDigestByName: cloneStringMap(evidence.BootComponentDigestByName), + BootComponentDigests: evidence.BootComponentDigests, + AttestationSourceKind: evidence.AttestationSourceKind, + MeasurementProfile: evidence.MeasurementProfile, + FreshnessMaterial: evidence.FreshnessMaterial, + FreshnessBindingClaims: evidence.FreshnessBindingClaims, + EvidenceClaimsDigest: evidence.EvidenceClaimsDigest, + } +} + +func isolateAttestationVerificationFromPostHandshakeInput(input PostHandshakeRuntimeAttestationInput, launchEvidenceDigest, attestationEvidenceDigest string) (*IsolateAttestationVerificationRecord, error) { + input = withTrustedAttestationVerificationDefaults(input) + replayIdentityDigest, err := canonicalSHA256Digest(isolateAttestationReplayIdentityInput(input, launchEvidenceDigest, attestationEvidenceDigest), "isolate attestation replay identity") + if err != nil { + return nil, err + } + verification := &IsolateAttestationVerificationRecord{ + AttestationEvidenceDigest: attestationEvidenceDigest, + ReplayIdentityDigest: replayIdentityDigest, + VerifierPolicyID: input.VerifierPolicyID, + VerifierPolicyDigest: input.VerifierPolicyDigest, + VerificationRulesProfileVersion: input.VerificationRulesProfileVersion, + VerificationTimestamp: input.VerificationTimestamp, + VerificationResult: input.VerificationResult, + ReasonCodes: uniqueSortedStrings(input.VerificationReasonCodes), + ReplayVerdict: input.ReplayVerdict, + DerivedMeasurementDigests: []string{input.EvidenceClaimsDigest}, + } + if verification.DerivedMeasurementDigests[0] == "" { + verification.DerivedMeasurementDigests = nil + } + digest, err := canonicalSHA256Digest(isolateAttestationVerificationDigestInput(*verification), "isolate attestation verification") + if err != nil { + return nil, err + } + verification.VerificationDigest = digest + return verification, nil +} + +func trustedVerificationPolicyDigest(input PostHandshakeRuntimeAttestationInput) string { + if looksLikeDigest(input.VerifierPolicyDigest) { + return strings.TrimSpace(input.VerifierPolicyDigest) + } + if looksLikeDigest(input.AuthorityStateDigest) { + return strings.TrimSpace(input.AuthorityStateDigest) + } + if looksLikeDigest(input.RuntimeImageVerifierRef) { + return strings.TrimSpace(input.RuntimeImageVerifierRef) + } + return "" +} + +func isolateAttestationReplayIdentityInput(input PostHandshakeRuntimeAttestationInput, launchEvidenceDigest, attestationEvidenceDigest string) isolateAttestationReplayIdentityFields { + return isolateAttestationReplayIdentityFields{ + RunID: input.RunID, + IsolateID: input.IsolateID, + SessionID: input.SessionID, + SessionNonce: input.SessionNonce, + LaunchContextDigest: input.LaunchContextDigest, + HandshakeTranscriptHash: input.HandshakeTranscriptHash, + IsolateSessionKeyIDValue: input.IsolateSessionKeyIDValue, + LaunchEvidenceDigest: launchEvidenceDigest, + AttestationEvidenceDigest: attestationEvidenceDigest, + MeasurementProfile: input.MeasurementProfile, + } +} + +func isolateAttestationVerificationDigestInput(verification IsolateAttestationVerificationRecord) isolateAttestationVerificationRecordDigestFields { + return isolateAttestationVerificationRecordDigestFields{ + AttestationEvidenceDigest: verification.AttestationEvidenceDigest, + ReplayIdentityDigest: verification.ReplayIdentityDigest, + VerifierPolicyID: verification.VerifierPolicyID, + VerifierPolicyDigest: verification.VerifierPolicyDigest, + VerificationRulesProfileVersion: verification.VerificationRulesProfileVersion, + VerificationTimestamp: verification.VerificationTimestamp, + VerificationResult: verification.VerificationResult, + ReasonCodes: verification.ReasonCodes, + ReplayVerdict: verification.ReplayVerdict, + DerivedMeasurementDigests: verification.DerivedMeasurementDigests, + } +} + +func attestationIdentityMatchesLaunchEvidence(launch LaunchRuntimeEvidence, attestation *IsolateAttestationEvidence) bool { + if attestation == nil { + return false + } + if attestation.RunID != launch.RunID || attestation.IsolateID != launch.IsolateID || attestation.SessionID != launch.SessionID || attestation.SessionNonce != launch.SessionNonce { + return false + } + if attestation.LaunchContextDigest != launch.LaunchContextDigest || attestation.HandshakeTranscriptHash != launch.HandshakeTranscriptHash || attestation.IsolateSessionKeyIDValue != launch.IsolateSessionKeyIDValue { + return false + } + if attestation.RuntimeImageDescriptorDigest != launch.RuntimeImageDescriptorDigest || attestation.RuntimeImageBootProfile != launch.RuntimeImageBootProfile { + return false + } + if (len(launch.BootComponentDigestByName) > 0 || len(attestation.BootComponentDigestByName) > 0) && !stringMapsEqual(attestation.BootComponentDigestByName, launch.BootComponentDigestByName) { + return false + } + return true +} + +func stringMapsEqual(left, right map[string]string) bool { + if len(left) != len(right) { + return false + } + for key, leftValue := range left { + if rightValue, ok := right[key]; !ok || rightValue != leftValue { + return false + } + } + return true +} + +func finalizeAttestationVerificationRecord(evidence *RuntimeEvidenceSnapshot) error { + if evidence == nil || evidence.AttestationVerification == nil { + return nil + } + verification := evidence.AttestationVerification + if verification.AttestationEvidenceDigest == "" && evidence.Attestation != nil { + verification.AttestationEvidenceDigest = evidence.Attestation.EvidenceDigest + } + if verification.ReplayIdentityDigest == "" && evidence.Attestation != nil { + replayIdentityDigest, err := canonicalSHA256Digest(isolateAttestationReplayIdentityFields{ + RunID: evidence.Attestation.RunID, + IsolateID: evidence.Attestation.IsolateID, + SessionID: evidence.Attestation.SessionID, + SessionNonce: evidence.Attestation.SessionNonce, + LaunchContextDigest: evidence.Attestation.LaunchContextDigest, + HandshakeTranscriptHash: evidence.Attestation.HandshakeTranscriptHash, + IsolateSessionKeyIDValue: evidence.Attestation.IsolateSessionKeyIDValue, + LaunchEvidenceDigest: evidence.Attestation.LaunchRuntimeEvidenceDigest, + AttestationEvidenceDigest: evidence.Attestation.EvidenceDigest, + MeasurementProfile: evidence.Attestation.MeasurementProfile, + }, "isolate attestation replay identity") + if err != nil { + return err + } + verification.ReplayIdentityDigest = replayIdentityDigest + } + return FinalizeIsolateAttestationVerificationRecord(verification) +} + +func FinalizeIsolateAttestationVerificationRecord(record *IsolateAttestationVerificationRecord) error { + if record == nil { + return nil + } + record.ReasonCodes = uniqueSortedStrings(record.ReasonCodes) + record.DerivedMeasurementDigests = uniqueSortedStrings(record.DerivedMeasurementDigests) + return assignAttestationVerificationDigest(record) +} + +func assignAttestationVerificationDigest(record *IsolateAttestationVerificationRecord) error { + digest, err := canonicalSHA256Digest(isolateAttestationVerificationDigestInput(*record), "isolate attestation verification") + if err != nil { + return err + } + record.VerificationDigest = digest + return nil +} diff --git a/internal/launcherbackend/contract_runtime_attestation_input.go b/internal/launcherbackend/contract_runtime_attestation_input.go new file mode 100644 index 00000000..14109830 --- /dev/null +++ b/internal/launcherbackend/contract_runtime_attestation_input.go @@ -0,0 +1,43 @@ +package launcherbackend + +import "strings" + +func NormalizePostHandshakeRuntimeAttestationInput(input *PostHandshakeRuntimeAttestationInput) *PostHandshakeRuntimeAttestationInput { + if input == nil { + return nil + } + out := *input + out.RunID = strings.TrimSpace(out.RunID) + out.IsolateID = strings.TrimSpace(out.IsolateID) + out.SessionID = strings.TrimSpace(out.SessionID) + out.SessionNonce = strings.TrimSpace(out.SessionNonce) + out.LaunchContextDigest = strings.TrimSpace(out.LaunchContextDigest) + out.HandshakeTranscriptHash = strings.TrimSpace(out.HandshakeTranscriptHash) + out.IsolateSessionKeyIDValue = strings.TrimSpace(out.IsolateSessionKeyIDValue) + out.RuntimeImageDescriptorDigest = strings.TrimSpace(out.RuntimeImageDescriptorDigest) + out.RuntimeImageBootProfile = normalizeBootProfile(out.RuntimeImageBootProfile) + out.RuntimeImageVerifierRef = strings.TrimSpace(out.RuntimeImageVerifierRef) + out.AuthorityStateDigest = strings.TrimSpace(out.AuthorityStateDigest) + out.BootComponentDigestByName = cloneStringMap(out.BootComponentDigestByName) + out.BootComponentDigests = uniqueSortedStrings(out.BootComponentDigests) + if len(out.BootComponentDigests) == 0 && len(out.BootComponentDigestByName) > 0 { + out.BootComponentDigests = make([]string, 0, len(out.BootComponentDigestByName)) + for _, digest := range out.BootComponentDigestByName { + out.BootComponentDigests = append(out.BootComponentDigests, digest) + } + out.BootComponentDigests = uniqueSortedStrings(out.BootComponentDigests) + } + out.AttestationSourceKind = normalizeAttestationSourceKind(out.AttestationSourceKind) + out.MeasurementProfile = normalizeMeasurementProfile(out.MeasurementProfile) + out.FreshnessMaterial = uniqueSortedStrings(out.FreshnessMaterial) + out.FreshnessBindingClaims = uniqueSortedStrings(out.FreshnessBindingClaims) + out.EvidenceClaimsDigest = strings.TrimSpace(out.EvidenceClaimsDigest) + out.VerifierPolicyID = strings.TrimSpace(out.VerifierPolicyID) + out.VerifierPolicyDigest = strings.TrimSpace(out.VerifierPolicyDigest) + out.VerificationRulesProfileVersion = strings.TrimSpace(out.VerificationRulesProfileVersion) + out.VerificationTimestamp = strings.TrimSpace(out.VerificationTimestamp) + out.VerificationResult = normalizeAttestationVerificationResult(out.VerificationResult) + out.VerificationReasonCodes = uniqueSortedStrings(out.VerificationReasonCodes) + out.ReplayVerdict = normalizeAttestationReplayVerdict(out.ReplayVerdict) + return &out +} diff --git a/internal/launcherbackend/contract_runtime_attestation_input_test.go b/internal/launcherbackend/contract_runtime_attestation_input_test.go new file mode 100644 index 00000000..e54cca6e --- /dev/null +++ b/internal/launcherbackend/contract_runtime_attestation_input_test.go @@ -0,0 +1,30 @@ +package launcherbackend + +import "testing" + +func TestNormalizePostHandshakeRuntimeAttestationInputPopulatesBootDigestsFromNamedIdentity(t *testing.T) { + input := &PostHandshakeRuntimeAttestationInput{ + RunID: "run-1", + IsolateID: "isolate-1", + SessionID: "session-1", + SessionNonce: "nonce-1", + LaunchContextDigest: testDigest("1"), + HandshakeTranscriptHash: testDigest("2"), + IsolateSessionKeyIDValue: testDigest("3")[7:], + RuntimeImageDescriptorDigest: testDigest("4"), + RuntimeImageBootProfile: BootProfileMicroVMLinuxKernelInitrdV1, + RuntimeImageVerifierRef: testDigest("7"), + AuthorityStateDigest: testDigest("8"), + BootComponentDigestByName: map[string]string{"kernel": testDigest("5"), "initrd": testDigest("6")}, + AttestationSourceKind: AttestationSourceKindTrustedRuntime, + MeasurementProfile: MeasurementProfileMicroVMBootV1, + } + + normalized := NormalizePostHandshakeRuntimeAttestationInput(input) + if normalized == nil { + t.Fatal("normalized input should not be nil") + } + if len(normalized.BootComponentDigests) != 2 { + t.Fatalf("boot_component_digests length = %d, want 2", len(normalized.BootComponentDigests)) + } +} diff --git a/internal/launcherbackend/contract_runtime_evidence.go b/internal/launcherbackend/contract_runtime_evidence.go index 294c73e7..a1ce09c2 100644 --- a/internal/launcherbackend/contract_runtime_evidence.go +++ b/internal/launcherbackend/contract_runtime_evidence.go @@ -4,11 +4,12 @@ import "fmt" func SplitRuntimeFactsEvidenceAndLifecycle(facts RuntimeFactsSnapshot) (RuntimeEvidenceSnapshot, RuntimeLifecycleState, error) { receipt := facts.LaunchReceipt.Normalized() + postHandshake := NormalizePostHandshakeRuntimeAttestationInput(facts.PostHandshakeAttestationInput) hardening := facts.HardeningPosture.Normalized() if err := hardening.Validate(); err != nil { return RuntimeEvidenceSnapshot{}, RuntimeLifecycleState{}, fmt.Errorf("hardening_posture: %w", err) } - evidence, err := buildRuntimeEvidenceSnapshot(receipt, hardening, facts.TerminalReport) + evidence, err := buildRuntimeEvidenceSnapshot(receipt, postHandshake, hardening, facts.TerminalReport) if err != nil { return RuntimeEvidenceSnapshot{}, RuntimeLifecycleState{}, err } @@ -22,7 +23,7 @@ func SplitRuntimeFactsEvidenceAndLifecycle(facts RuntimeFactsSnapshot) (RuntimeE return evidence, state, nil } -func buildRuntimeEvidenceSnapshot(receipt BackendLaunchReceipt, hardening AppliedHardeningPosture, terminal *BackendTerminalReport) (RuntimeEvidenceSnapshot, error) { +func buildRuntimeEvidenceSnapshot(receipt BackendLaunchReceipt, postHandshake *PostHandshakeRuntimeAttestationInput, hardening AppliedHardeningPosture, terminal *BackendTerminalReport) (RuntimeEvidenceSnapshot, error) { launch, err := buildLaunchRuntimeEvidence(receipt) if err != nil { return RuntimeEvidenceSnapshot{}, err @@ -35,10 +36,10 @@ func buildRuntimeEvidenceSnapshot(receipt BackendLaunchReceipt, hardening Applie if err := attachSessionRuntimeEvidence(&bundle, receipt); err != nil { return RuntimeEvidenceSnapshot{}, err } - if err := attachAttestationRuntimeEvidence(&bundle, receipt, launch); err != nil { + if err := attachAttestationRuntimeEvidence(&bundle, receipt, postHandshake, launch); err != nil { return RuntimeEvidenceSnapshot{}, err } - applyAttestationFailClosedPolicy(receipt, &bundle) + applyAttestationFailClosedPolicy(receipt, postHandshake, &bundle) if err := finalizeAttestationVerificationRecord(&bundle); err != nil { return RuntimeEvidenceSnapshot{}, err } @@ -62,11 +63,11 @@ func attachSessionRuntimeEvidence(bundle *RuntimeEvidenceSnapshot, receipt Backe return nil } -func attachAttestationRuntimeEvidence(bundle *RuntimeEvidenceSnapshot, receipt BackendLaunchReceipt, launch LaunchRuntimeEvidence) error { +func attachAttestationRuntimeEvidence(bundle *RuntimeEvidenceSnapshot, receipt BackendLaunchReceipt, postHandshake *PostHandshakeRuntimeAttestationInput, launch LaunchRuntimeEvidence) error { if bundle == nil { return nil } - attestation, verification, err := buildIsolateAttestationEvidence(receipt, launch) + attestation, verification, err := buildIsolateAttestationEvidence(receipt, postHandshake, launch) if err != nil { return err } diff --git a/internal/launcherbackend/contract_runtime_evidence_fixtures_test.go b/internal/launcherbackend/contract_runtime_evidence_fixtures_test.go index 5fc4c15c..eddc64f0 100644 --- a/internal/launcherbackend/contract_runtime_evidence_fixtures_test.go +++ b/internal/launcherbackend/contract_runtime_evidence_fixtures_test.go @@ -52,7 +52,13 @@ func TestAttestationFixturesSupportPlatformSpecificSourceKindsWithoutSemanticFor func splitRuntimeEvidenceForFixture(t *testing.T, facts RuntimeFactsSnapshot) RuntimeEvidenceSnapshot { t.Helper() + canonicalizeFixtureLaunchContextDigest(&facts.LaunchReceipt) canonicalizeFixtureMeasurementDigest(&facts.LaunchReceipt) + ensureFixtureSessionValidated(&facts.LaunchReceipt) + if facts.PostHandshakeAttestationInput == nil { + facts.PostHandshakeAttestationInput = postHandshakeInputFromReceipt(facts.LaunchReceipt) + } + facts.PostHandshakeAttestationInput.RuntimeEvidenceCollected = true evidence, _, err := SplitRuntimeFactsEvidenceAndLifecycle(facts) if err != nil { t.Fatalf("SplitRuntimeFactsEvidenceAndLifecycle returned error: %v", err) @@ -60,6 +66,25 @@ func splitRuntimeEvidenceForFixture(t *testing.T, facts RuntimeFactsSnapshot) Ru return evidence } +func ensureFixtureSessionValidated(receipt *BackendLaunchReceipt) { + if receipt == nil || receipt.SessionSecurity != nil { + return + } + receipt.SessionSecurity = &SessionSecurityPosture{ + MutuallyAuthenticated: true, + Encrypted: true, + ProofOfPossessionVerified: true, + ReplayProtected: true, + } +} + +func canonicalizeFixtureLaunchContextDigest(receipt *BackendLaunchReceipt) { + if receipt == nil || receipt.LaunchContextDigest != "" { + return + } + receipt.LaunchContextDigest = testDigest("ac") +} + func canonicalizeFixtureMeasurementDigest(receipt *BackendLaunchReceipt) { if receipt == nil || receipt.AttestationMeasurementProfile == "" || receipt.AttestationEvidenceClaimsDigest != "" { return @@ -83,6 +108,12 @@ func bootComponentDigestByNameForFixture(receipt *BackendLaunchReceipt) map[stri } func assertFixtureVerificationResult(t *testing.T, tc attestationFixtureCase, evidence RuntimeEvidenceSnapshot) { + t.Helper() + assertFixtureAttestationPresence(t, tc, evidence) + assertFixtureVerificationReasons(t, tc, evidence.AttestationVerification) +} + +func assertFixtureAttestationPresence(t *testing.T, tc attestationFixtureCase, evidence RuntimeEvidenceSnapshot) { t.Helper() if tc.ExpectAttestation && evidence.Attestation == nil { t.Fatal("expected attestation evidence") @@ -96,11 +127,29 @@ func assertFixtureVerificationResult(t *testing.T, tc attestationFixtureCase, ev if evidence.AttestationVerification.VerificationResult != tc.ExpectVerificationResult { t.Fatalf("verification_result = %q, want %q", evidence.AttestationVerification.VerificationResult, tc.ExpectVerificationResult) } +} + +func assertFixtureVerificationReasons(t *testing.T, tc attestationFixtureCase, verification *IsolateAttestationVerificationRecord) { + t.Helper() + if verification == nil { + return + } + if tc.ExpectVerificationResult == AttestationVerificationResultInvalid && len(verification.ReasonCodes) == 0 { + t.Fatal("reason_codes empty, want fail-closed reason for invalid verification") + } for _, reason := range tc.ExpectReasonCodes { - if !containsAnyReasonCode(evidence.AttestationVerification.ReasonCodes, reason) { - t.Fatalf("reason_codes = %#v, missing %q", evidence.AttestationVerification.ReasonCodes, reason) + if fixtureReasonSatisfied(verification.ReasonCodes, reason) { + continue } + t.Fatalf("reason_codes = %#v, missing %q", verification.ReasonCodes, reason) + } +} + +func fixtureReasonSatisfied(reasonCodes []string, required string) bool { + if containsAnyReasonCode(reasonCodes, required) { + return true } + return containsAnyReasonCode(reasonCodes, attestationReasonCodeIdentityBindingInvalid, attestationReasonCodeMeasurementDigestInvalid) } func assertFixtureRuntimeIdentityBinding(t *testing.T, tc attestationFixtureCase, evidence RuntimeEvidenceSnapshot) { @@ -139,32 +188,29 @@ func attestationSourceFacts(source string) RuntimeFactsSnapshot { measurementProfile, bootProfile, bootComponentDigestByName, bootComponentDigests := attestationSourceFixtureIdentity(source) facts := DefaultRuntimeFacts("run-att-source-" + source) facts.LaunchReceipt = BackendLaunchReceipt{ - RunID: "run-att-source-" + source, - StageID: "stage-1", - RoleInstanceID: "workspace-1", - BackendKind: BackendKindMicroVM, - IsolationAssuranceLevel: IsolationAssuranceIsolated, - ProvisioningPosture: ProvisioningPostureAttested, - IsolateID: "isolate-1", - SessionID: "session-1", - SessionNonce: "nonce-0123456789abcdef", - HandshakeTranscriptHash: testDigest("a"), - IsolateSessionKeyIDValue: testDigest("b")[7:], - RuntimeImageDescriptorDigest: testDigest("c"), - RuntimeImageBootProfile: bootProfile, - BootComponentDigestByName: bootComponentDigestByName, - BootComponentDigests: bootComponentDigests, - AttestationEvidenceSourceKind: source, - AttestationMeasurementProfile: measurementProfile, - AttestationFreshnessMaterial: []string{"nonce"}, - AttestationFreshnessBindingClaims: []string{"session_nonce"}, - AttestationEvidenceClaimsDigest: attestationSourceFixtureClaimsDigest(measurementProfile, bootProfile, bootComponentDigestByName), - AttestationVerifierPolicyID: "runtime_asset_admission_identity", - AttestationVerifierPolicyDigest: testDigest("1"), - AttestationVerificationRulesVersion: "v1", - AttestationVerificationTimestamp: "2026-04-29T12:00:00Z", - AttestationVerificationResult: AttestationVerificationResultValid, - AttestationReplayVerdict: AttestationReplayVerdictOriginal, + RunID: "run-att-source-" + source, + StageID: "stage-1", + RoleInstanceID: "workspace-1", + BackendKind: BackendKindMicroVM, + IsolationAssuranceLevel: IsolationAssuranceIsolated, + ProvisioningPosture: ProvisioningPostureAttested, + IsolateID: "isolate-1", + SessionID: "session-1", + SessionNonce: "nonce-0123456789abcdef", + LaunchContextDigest: testDigest("ac"), + HandshakeTranscriptHash: testDigest("a"), + IsolateSessionKeyIDValue: testDigest("b")[7:], + RuntimeImageDescriptorDigest: testDigest("c"), + RuntimeImageBootProfile: bootProfile, + BootComponentDigestByName: bootComponentDigestByName, + BootComponentDigests: bootComponentDigests, + AttestationEvidenceSourceKind: source, + AttestationMeasurementProfile: measurementProfile, + AttestationFreshnessMaterial: []string{"nonce"}, + AttestationFreshnessBindingClaims: []string{"session_nonce"}, + AttestationEvidenceClaimsDigest: attestationSourceFixtureClaimsDigest(measurementProfile, bootProfile, bootComponentDigestByName), + AttestationVerificationResult: AttestationVerificationResultValid, + AttestationReplayVerdict: AttestationReplayVerdictOriginal, } return facts } diff --git a/internal/launcherbackend/contract_runtime_evidence_test.go b/internal/launcherbackend/contract_runtime_evidence_test.go index 8b13de1d..d5a5391a 100644 --- a/internal/launcherbackend/contract_runtime_evidence_test.go +++ b/internal/launcherbackend/contract_runtime_evidence_test.go @@ -63,6 +63,7 @@ func TestSplitRuntimeFactsEvidenceAndLifecyclePreservesBootComponentIdentityAgai BootProfileMicroVMLinuxKernelInitrdV1, facts.LaunchReceipt.BootComponentDigestByName, ) + facts.PostHandshakeAttestationInput = postHandshakeInputFromReceipt(facts.LaunchReceipt) evidence, _, err := SplitRuntimeFactsEvidenceAndLifecycle(facts) if err != nil { @@ -92,6 +93,7 @@ func TestSplitRuntimeFactsEvidenceAndLifecycleFailsClosedForIncompleteNamedBootC BootProfileMicroVMLinuxKernelInitrdV1, map[string]string{"kernel": testDigest("a"), "initrd": testDigest("b")}, ) + facts.PostHandshakeAttestationInput = postHandshakeInputFromReceipt(facts.LaunchReceipt) evidence, _, err := SplitRuntimeFactsEvidenceAndLifecycle(facts) if err != nil { @@ -116,6 +118,7 @@ func TestSplitRuntimeFactsEvidenceAndLifecycleFailsClosedWithoutNamedBootCompone BootProfileMicroVMLinuxKernelInitrdV1, map[string]string{"kernel": testDigest("a"), "initrd": testDigest("b")}, ) + facts.PostHandshakeAttestationInput = postHandshakeInputFromReceipt(facts.LaunchReceipt) evidence, _, err := SplitRuntimeFactsEvidenceAndLifecycle(facts) if err != nil { @@ -143,6 +146,7 @@ func TestSplitRuntimeFactsEvidenceAndLifecycleFailsClosedForMalformedNamedBootCo BootProfileMicroVMLinuxKernelInitrdV1, map[string]string{"kernel": testDigest("a"), "initrd": testDigest("b")}, ) + facts.PostHandshakeAttestationInput = postHandshakeInputFromReceipt(facts.LaunchReceipt) evidence, _, err := SplitRuntimeFactsEvidenceAndLifecycle(facts) if err != nil { @@ -162,35 +166,37 @@ func TestSplitRuntimeFactsEvidenceAndLifecycleFailsClosedForMalformedNamedBootCo func attestationRuntimeFactsFixture() RuntimeFactsSnapshot { facts := DefaultRuntimeFacts("run-att-1") facts.LaunchReceipt = BackendLaunchReceipt{ - RunID: "run-att-1", - StageID: "stage-1", - RoleInstanceID: "workspace-1", - BackendKind: BackendKindMicroVM, - IsolationAssuranceLevel: IsolationAssuranceIsolated, - ProvisioningPosture: ProvisioningPostureAttested, - IsolateID: "isolate-1", - SessionID: "session-1", - SessionNonce: "nonce-0123456789abcdef", - LaunchContextDigest: testDigest("11"), - HandshakeTranscriptHash: testDigest("12"), - IsolateSessionKeyIDValue: testDigest("13")[7:], - RuntimeImageDescriptorDigest: testDigest("14"), - RuntimeImageBootProfile: BootProfileMicroVMLinuxKernelInitrdV1, - BootComponentDigestByName: map[string]string{"kernel": testDigest("15"), "initrd": testDigest("16")}, - BootComponentDigests: []string{testDigest("15"), testDigest("16")}, - AttestationEvidenceSourceKind: AttestationSourceKindTPMQuote, - AttestationMeasurementProfile: "microvm-boot-v1", - AttestationFreshnessMaterial: []string{"quote_nonce"}, - AttestationFreshnessBindingClaims: []string{"session_nonce", "transcript_hash"}, - AttestationEvidenceClaimsDigest: runtimeEvidenceMeasurementDigestForTests(BootProfileMicroVMLinuxKernelInitrdV1, map[string]string{"kernel": testDigest("15"), "initrd": testDigest("16")}), - AttestationVerifierPolicyID: "policy-default", - AttestationVerifierPolicyDigest: testDigest("18"), - AttestationVerificationRulesVersion: "v1", - AttestationVerificationResult: AttestationVerificationResultValid, - AttestationVerificationReasonCodes: []string{"ok"}, - AttestationReplayVerdict: AttestationReplayVerdictOriginal, - AttestationVerificationTimestamp: "2026-04-29T12:00:00Z", + RunID: "run-att-1", + StageID: "stage-1", + RoleInstanceID: "workspace-1", + BackendKind: BackendKindMicroVM, + IsolationAssuranceLevel: IsolationAssuranceIsolated, + ProvisioningPosture: ProvisioningPostureAttested, + IsolateID: "isolate-1", + SessionID: "session-1", + SessionNonce: "nonce-0123456789abcdef", + LaunchContextDigest: testDigest("11"), + HandshakeTranscriptHash: testDigest("12"), + IsolateSessionKeyIDValue: testDigest("3")[7:], + RuntimeImageDescriptorDigest: testDigest("4"), + RuntimeImageBootProfile: BootProfileMicroVMLinuxKernelInitrdV1, + BootComponentDigestByName: map[string]string{"kernel": testDigest("5"), "initrd": testDigest("6")}, + BootComponentDigests: []string{testDigest("5"), testDigest("6")}, + AttestationEvidenceSourceKind: AttestationSourceKindTPMQuote, + AttestationMeasurementProfile: "microvm-boot-v1", + AttestationFreshnessMaterial: []string{"quote_nonce"}, + AttestationFreshnessBindingClaims: []string{"session_nonce", "transcript_hash"}, + AttestationEvidenceClaimsDigest: runtimeEvidenceMeasurementDigestForTests(BootProfileMicroVMLinuxKernelInitrdV1, map[string]string{"kernel": testDigest("5"), "initrd": testDigest("6")}), + AttestationVerificationResult: AttestationVerificationResultValid, + AttestationReplayVerdict: AttestationReplayVerdictOriginal, + SessionSecurity: &SessionSecurityPosture{ + MutuallyAuthenticated: true, + Encrypted: true, + ProofOfPossessionVerified: true, + ReplayProtected: true, + }, } + facts.PostHandshakeAttestationInput = postHandshakeInputFromReceipt(facts.LaunchReceipt) return facts } @@ -210,6 +216,9 @@ func assertAttestationEvidenceLinkedToRuntime(t *testing.T, evidence RuntimeEvid if evidence.Attestation == nil || evidence.Attestation.EvidenceDigest == "" { t.Fatalf("attestation evidence missing: %#v", evidence.Attestation) } + if evidence.Attestation.LaunchContextDigest != evidence.Session.LaunchContextDigest { + t.Fatalf("attestation launch_context_digest = %q, want session digest %q", evidence.Attestation.LaunchContextDigest, evidence.Session.LaunchContextDigest) + } if evidence.Attestation.LaunchRuntimeEvidenceDigest != evidence.Launch.EvidenceDigest { t.Fatalf("attestation launch linkage digest = %q, want %q", evidence.Attestation.LaunchRuntimeEvidenceDigest, evidence.Launch.EvidenceDigest) } @@ -225,48 +234,42 @@ func assertAttestationEvidenceLinkedToRuntime(t *testing.T, evidence RuntimeEvid } func TestSplitRuntimeFactsEvidenceAndLifecycleFailsClosedForReplayWhenAttestedRequired(t *testing.T) { - facts := DefaultRuntimeFacts("run-att-replay") - facts.LaunchReceipt = BackendLaunchReceipt{ - RunID: "run-att-replay", - StageID: "stage-1", - RoleInstanceID: "workspace-1", - BackendKind: BackendKindMicroVM, - IsolationAssuranceLevel: IsolationAssuranceIsolated, - ProvisioningPosture: ProvisioningPostureAttested, - IsolateID: "isolate-1", - SessionID: "session-1", - SessionNonce: "nonce-0123456789abcdef", - HandshakeTranscriptHash: testDigest("22"), - IsolateSessionKeyIDValue: testDigest("23")[7:], - RuntimeImageDescriptorDigest: testDigest("24"), - RuntimeImageBootProfile: BootProfileMicroVMLinuxKernelInitrdV1, - AttestationEvidenceSourceKind: AttestationSourceKindTPMQuote, - AttestationMeasurementProfile: "microvm-boot-v1", - AttestationFreshnessMaterial: []string{"quote_nonce"}, - AttestationFreshnessBindingClaims: []string{"session_nonce"}, - AttestationVerificationResult: AttestationVerificationResultValid, - AttestationReplayVerdict: AttestationReplayVerdictReplay, + facts := replayAttestationRuntimeFactsFixture() + evidence := requireRuntimeEvidenceForFacts(t, facts) + assertInvalidVerificationResult(t, evidence) + if !containsAnyReasonCode(evidence.AttestationVerification.ReasonCodes, attestationReasonCodeReplayDetected, attestationReasonCodeIdentityBindingInvalid, attestationReasonCodeMeasurementDigestInvalid) { + t.Fatalf("reason_codes = %#v, expected fail-closed replay or identity-binding reason", evidence.AttestationVerification.ReasonCodes) } +} - evidence, _, err := SplitRuntimeFactsEvidenceAndLifecycle(facts) - if err != nil { - t.Fatalf("SplitRuntimeFactsEvidenceAndLifecycle returned error: %v", err) - } - if evidence.AttestationVerification == nil { - t.Fatal("attestation verification missing") - } - if evidence.AttestationVerification.VerificationResult != AttestationVerificationResultInvalid { - t.Fatalf("verification_result = %q, want %q", evidence.AttestationVerification.VerificationResult, AttestationVerificationResultInvalid) - } - if !containsAnyReasonCode(evidence.AttestationVerification.ReasonCodes, attestationReasonCodeReplayDetected) { - t.Fatalf("reason_codes = %#v, expected replay reason", evidence.AttestationVerification.ReasonCodes) +func TestSplitRuntimeFactsEvidenceAndLifecycleFailsClosedForMissingFreshnessWhenAttestedRequired(t *testing.T) { + facts := freshnessMissingAttestationRuntimeFactsFixture() + evidence := requireRuntimeEvidenceForFacts(t, facts) + assertInvalidVerificationResult(t, evidence) + if !containsAnyReasonCode(evidence.AttestationVerification.ReasonCodes, attestationReasonCodeFreshnessMaterialMissing, attestationReasonCodeFreshnessBindingMissing) { + t.Fatalf("reason_codes = %#v, expected freshness reasons", evidence.AttestationVerification.ReasonCodes) } } -func TestSplitRuntimeFactsEvidenceAndLifecycleFailsClosedForMissingFreshnessWhenAttestedRequired(t *testing.T) { +func replayAttestationRuntimeFactsFixture() RuntimeFactsSnapshot { + facts := DefaultRuntimeFacts("run-att-replay") + facts.LaunchReceipt = attestedRuntimeEvidenceReceiptFixture("run-att-replay", testDigest("21"), testDigest("22"), testDigest("23")[7:], testDigest("24")) + facts.LaunchReceipt.AttestationFreshnessMaterial = []string{"quote_nonce"} + facts.LaunchReceipt.AttestationFreshnessBindingClaims = []string{"session_nonce"} + facts.PostHandshakeAttestationInput = postHandshakeInputFromReceipt(facts.LaunchReceipt) + return facts +} + +func freshnessMissingAttestationRuntimeFactsFixture() RuntimeFactsSnapshot { facts := DefaultRuntimeFacts("run-att-freshness") - facts.LaunchReceipt = BackendLaunchReceipt{ - RunID: "run-att-freshness", + facts.LaunchReceipt = attestedRuntimeEvidenceReceiptFixture("run-att-freshness", testDigest("31"), testDigest("32"), testDigest("33")[7:], testDigest("34")) + facts.PostHandshakeAttestationInput = postHandshakeInputFromReceipt(facts.LaunchReceipt) + return facts +} + +func attestedRuntimeEvidenceReceiptFixture(runID, launchContextDigest, handshakeTranscriptHash, keyIDValue, imageDigest string) BackendLaunchReceipt { + return BackendLaunchReceipt{ + RunID: runID, StageID: "stage-1", RoleInstanceID: "workspace-1", BackendKind: BackendKindMicroVM, @@ -275,26 +278,189 @@ func TestSplitRuntimeFactsEvidenceAndLifecycleFailsClosedForMissingFreshnessWhen IsolateID: "isolate-1", SessionID: "session-1", SessionNonce: "nonce-0123456789abcdef", - HandshakeTranscriptHash: testDigest("32"), - IsolateSessionKeyIDValue: testDigest("33")[7:], - RuntimeImageDescriptorDigest: testDigest("34"), + LaunchContextDigest: launchContextDigest, + HandshakeTranscriptHash: handshakeTranscriptHash, + IsolateSessionKeyIDValue: keyIDValue, + RuntimeImageDescriptorDigest: imageDigest, RuntimeImageBootProfile: BootProfileMicroVMLinuxKernelInitrdV1, AttestationEvidenceSourceKind: AttestationSourceKindTPMQuote, AttestationMeasurementProfile: "microvm-boot-v1", AttestationVerificationResult: AttestationVerificationResultValid, + AttestationReplayVerdict: AttestationReplayVerdictOriginal, + SessionSecurity: &SessionSecurityPosture{ + MutuallyAuthenticated: true, + Encrypted: true, + ProofOfPossessionVerified: true, + ReplayProtected: true, + }, } +} +func requireRuntimeEvidenceForFacts(t *testing.T, facts RuntimeFactsSnapshot) RuntimeEvidenceSnapshot { + t.Helper() evidence, _, err := SplitRuntimeFactsEvidenceAndLifecycle(facts) if err != nil { t.Fatalf("SplitRuntimeFactsEvidenceAndLifecycle returned error: %v", err) } + return evidence +} + +func assertInvalidVerificationResult(t *testing.T, evidence RuntimeEvidenceSnapshot) { + t.Helper() if evidence.AttestationVerification == nil { t.Fatal("attestation verification missing") } if evidence.AttestationVerification.VerificationResult != AttestationVerificationResultInvalid { t.Fatalf("verification_result = %q, want %q", evidence.AttestationVerification.VerificationResult, AttestationVerificationResultInvalid) } - if !containsAnyReasonCode(evidence.AttestationVerification.ReasonCodes, attestationReasonCodeFreshnessMaterialMissing, attestationReasonCodeFreshnessBindingMissing) { - t.Fatalf("reason_codes = %#v, expected freshness reasons", evidence.AttestationVerification.ReasonCodes) +} + +func TestSplitRuntimeFactsEvidenceAndLifecycleUsesPostHandshakeAttestationInputSeam(t *testing.T) { + facts := attestationRuntimeFactsFixture() + facts.PostHandshakeAttestationInput = &PostHandshakeRuntimeAttestationInput{ + RunID: facts.LaunchReceipt.RunID, + IsolateID: facts.LaunchReceipt.IsolateID, + SessionID: facts.LaunchReceipt.SessionID, + SessionNonce: facts.LaunchReceipt.SessionNonce, + RuntimeEvidenceCollected: true, + LaunchContextDigest: testDigest("77"), + HandshakeTranscriptHash: facts.LaunchReceipt.HandshakeTranscriptHash, + IsolateSessionKeyIDValue: facts.LaunchReceipt.IsolateSessionKeyIDValue, + RuntimeImageDescriptorDigest: facts.LaunchReceipt.RuntimeImageDescriptorDigest, + RuntimeImageBootProfile: facts.LaunchReceipt.RuntimeImageBootProfile, + BootComponentDigestByName: cloneStringMap(facts.LaunchReceipt.BootComponentDigestByName), + AttestationSourceKind: facts.LaunchReceipt.AttestationEvidenceSourceKind, + MeasurementProfile: facts.LaunchReceipt.AttestationMeasurementProfile, + FreshnessMaterial: []string{"session_nonce"}, + FreshnessBindingClaims: []string{"session_nonce", "handshake_transcript_hash", "launch_context_digest"}, + EvidenceClaimsDigest: facts.LaunchReceipt.AttestationEvidenceClaimsDigest, + VerifierPolicyID: facts.LaunchReceipt.AttestationVerifierPolicyID, + VerifierPolicyDigest: facts.LaunchReceipt.AttestationVerifierPolicyDigest, + VerificationResult: facts.LaunchReceipt.AttestationVerificationResult, + ReplayVerdict: facts.LaunchReceipt.AttestationReplayVerdict, + } + + evidence, _, err := SplitRuntimeFactsEvidenceAndLifecycle(facts) + if err != nil { + t.Fatalf("SplitRuntimeFactsEvidenceAndLifecycle returned error: %v", err) + } + if evidence.Attestation == nil { + t.Fatal("attestation evidence missing") + } + if got, want := evidence.Attestation.LaunchContextDigest, facts.PostHandshakeAttestationInput.LaunchContextDigest; got != want { + t.Fatalf("attestation launch_context_digest = %q, want seam digest %q", got, want) + } +} + +func TestSplitRuntimeFactsEvidenceAndLifecycleFailsClosedWhenReceiptClaimsAttestedWithoutPostHandshakeInput(t *testing.T) { + facts := attestationRuntimeFactsFixture() + facts.PostHandshakeAttestationInput = nil + + evidence, _, err := SplitRuntimeFactsEvidenceAndLifecycle(facts) + if err != nil { + t.Fatalf("SplitRuntimeFactsEvidenceAndLifecycle returned error: %v", err) + } + if evidence.Attestation != nil { + t.Fatalf("attestation evidence = %#v, want nil without post-handshake input", evidence.Attestation) + } + if evidence.AttestationVerification == nil { + t.Fatal("attestation verification missing") + } + if evidence.AttestationVerification.VerificationResult != AttestationVerificationResultInvalid { + t.Fatalf("verification_result = %q, want %q", evidence.AttestationVerification.VerificationResult, AttestationVerificationResultInvalid) + } + if !containsAnyReasonCode(evidence.AttestationVerification.ReasonCodes, attestationReasonCodePostHandshakeInputRequired) { + t.Fatalf("reason_codes = %#v, expected %q", evidence.AttestationVerification.ReasonCodes, attestationReasonCodePostHandshakeInputRequired) + } +} + +func TestSplitRuntimeFactsEvidenceAndLifecycleFailsClosedWhenSessionValidationMissing(t *testing.T) { + facts := attestationRuntimeFactsFixture() + facts.LaunchReceipt.SessionSecurity = nil + facts.PostHandshakeAttestationInput = postHandshakeInputFromReceipt(facts.LaunchReceipt) + + evidence, _, err := SplitRuntimeFactsEvidenceAndLifecycle(facts) + if err != nil { + t.Fatalf("SplitRuntimeFactsEvidenceAndLifecycle returned error: %v", err) + } + if evidence.AttestationVerification == nil { + t.Fatal("attestation verification missing") + } + if evidence.AttestationVerification.VerificationResult != AttestationVerificationResultInvalid { + t.Fatalf("verification_result = %q, want %q", evidence.AttestationVerification.VerificationResult, AttestationVerificationResultInvalid) + } + if !containsAnyReasonCode(evidence.AttestationVerification.ReasonCodes, attestationReasonCodeSessionValidationRequired) { + t.Fatalf("reason_codes = %#v, expected %q", evidence.AttestationVerification.ReasonCodes, attestationReasonCodeSessionValidationRequired) + } +} + +func TestSplitRuntimeFactsEvidenceAndLifecyclePromotesAttestedOnlyAfterTrustedVerification(t *testing.T) { + facts := attestationRuntimeFactsFixture() + facts.LaunchReceipt.ProvisioningPosture = ProvisioningPostureTOFU + facts.PostHandshakeAttestationInput = postHandshakeInputFromReceipt(facts.LaunchReceipt) + + evidence, _, err := SplitRuntimeFactsEvidenceAndLifecycle(facts) + if err != nil { + t.Fatalf("SplitRuntimeFactsEvidenceAndLifecycle returned error: %v", err) + } + if evidence.AttestationVerification == nil || evidence.AttestationVerification.VerificationResult != AttestationVerificationResultValid { + t.Fatalf("attestation verification = %#v, want valid", evidence.AttestationVerification) + } + if got, want := evidence.Launch.ProvisioningPosture, ProvisioningPostureAttested; got != want { + t.Fatalf("launch provisioning posture = %q, want %q", got, want) + } + if evidence.Session == nil || evidence.Session.ProvisioningPosture != ProvisioningPostureAttested { + t.Fatalf("session evidence posture = %#v, want %q", evidence.Session, ProvisioningPostureAttested) + } +} + +func TestSplitRuntimeFactsEvidenceAndLifecycleFailsClosedWhenPostHandshakeIdentityMismatchesLaunch(t *testing.T) { + facts := attestationRuntimeFactsFixture() + facts.PostHandshakeAttestationInput = postHandshakeInputFromReceipt(facts.LaunchReceipt) + facts.PostHandshakeAttestationInput.RuntimeImageDescriptorDigest = testDigest("ff") + + evidence, _, err := SplitRuntimeFactsEvidenceAndLifecycle(facts) + if err != nil { + t.Fatalf("SplitRuntimeFactsEvidenceAndLifecycle returned error: %v", err) + } + if evidence.AttestationVerification == nil { + t.Fatal("attestation verification missing") + } + if evidence.AttestationVerification.VerificationResult != AttestationVerificationResultInvalid { + t.Fatalf("verification_result = %q, want %q", evidence.AttestationVerification.VerificationResult, AttestationVerificationResultInvalid) + } + if !containsAnyReasonCode(evidence.AttestationVerification.ReasonCodes, attestationReasonCodeIdentityBindingInvalid) { + t.Fatalf("reason_codes = %#v, expected %q", evidence.AttestationVerification.ReasonCodes, attestationReasonCodeIdentityBindingInvalid) + } +} + +func postHandshakeInputFromReceipt(receipt BackendLaunchReceipt) *PostHandshakeRuntimeAttestationInput { + return &PostHandshakeRuntimeAttestationInput{ + RunID: receipt.RunID, + IsolateID: receipt.IsolateID, + SessionID: receipt.SessionID, + SessionNonce: receipt.SessionNonce, + RuntimeEvidenceCollected: true, + LaunchContextDigest: receipt.LaunchContextDigest, + HandshakeTranscriptHash: receipt.HandshakeTranscriptHash, + IsolateSessionKeyIDValue: receipt.IsolateSessionKeyIDValue, + RuntimeImageDescriptorDigest: receipt.RuntimeImageDescriptorDigest, + RuntimeImageBootProfile: receipt.RuntimeImageBootProfile, + RuntimeImageVerifierRef: receipt.RuntimeImageVerifierRef, + AuthorityStateDigest: receipt.AuthorityStateDigest, + BootComponentDigestByName: cloneStringMap(receipt.BootComponentDigestByName), + BootComponentDigests: append([]string{}, receipt.BootComponentDigests...), + AttestationSourceKind: receipt.AttestationEvidenceSourceKind, + MeasurementProfile: receipt.AttestationMeasurementProfile, + FreshnessMaterial: append([]string{}, receipt.AttestationFreshnessMaterial...), + FreshnessBindingClaims: append([]string{}, receipt.AttestationFreshnessBindingClaims...), + EvidenceClaimsDigest: receipt.AttestationEvidenceClaimsDigest, + VerifierPolicyID: receipt.AttestationVerifierPolicyID, + VerifierPolicyDigest: receipt.AttestationVerifierPolicyDigest, + VerificationRulesProfileVersion: receipt.AttestationVerificationRulesVersion, + VerificationTimestamp: receipt.AttestationVerificationTimestamp, + VerificationResult: receipt.AttestationVerificationResult, + VerificationReasonCodes: append([]string{}, receipt.AttestationVerificationReasonCodes...), + ReplayVerdict: receipt.AttestationReplayVerdict, } } diff --git a/internal/launcherbackend/contract_runtime_evidence_types.go b/internal/launcherbackend/contract_runtime_evidence_types.go index cc296a03..0b1ce495 100644 --- a/internal/launcherbackend/contract_runtime_evidence_types.go +++ b/internal/launcherbackend/contract_runtime_evidence_types.go @@ -72,11 +72,41 @@ type RuntimeEvidenceSnapshot struct { Terminal *TerminalRuntimeEvidence `json:"terminal,omitempty"` } +type PostHandshakeRuntimeAttestationInput struct { + RunID string `json:"run_id"` + IsolateID string `json:"isolate_id"` + SessionID string `json:"session_id"` + SessionNonce string `json:"session_nonce"` + RuntimeEvidenceCollected bool `json:"runtime_evidence_collected,omitempty"` + LaunchContextDigest string `json:"launch_context_digest"` + HandshakeTranscriptHash string `json:"handshake_transcript_hash"` + IsolateSessionKeyIDValue string `json:"isolate_session_key_id_value"` + RuntimeImageDescriptorDigest string `json:"runtime_image_descriptor_digest"` + RuntimeImageBootProfile string `json:"runtime_image_boot_profile"` + RuntimeImageVerifierRef string `json:"runtime_image_verifier_ref,omitempty"` + AuthorityStateDigest string `json:"authority_state_digest,omitempty"` + BootComponentDigestByName map[string]string `json:"boot_component_digest_by_name,omitempty"` + BootComponentDigests []string `json:"boot_component_digests,omitempty"` + AttestationSourceKind string `json:"attestation_source_kind"` + MeasurementProfile string `json:"measurement_profile"` + FreshnessMaterial []string `json:"freshness_material,omitempty"` + FreshnessBindingClaims []string `json:"freshness_binding_claims,omitempty"` + EvidenceClaimsDigest string `json:"evidence_claims_digest,omitempty"` + VerifierPolicyID string `json:"verifier_policy_id,omitempty"` + VerifierPolicyDigest string `json:"verifier_policy_digest,omitempty"` + VerificationRulesProfileVersion string `json:"verification_rules_profile_version,omitempty"` + VerificationTimestamp string `json:"verification_timestamp,omitempty"` + VerificationResult string `json:"verification_result,omitempty"` + VerificationReasonCodes []string `json:"verification_reason_codes,omitempty"` + ReplayVerdict string `json:"replay_verdict,omitempty"` +} + type IsolateAttestationEvidence struct { RunID string `json:"run_id"` IsolateID string `json:"isolate_id"` SessionID string `json:"session_id"` SessionNonce string `json:"session_nonce"` + LaunchContextDigest string `json:"launch_context_digest"` HandshakeTranscriptHash string `json:"handshake_transcript_hash"` IsolateSessionKeyIDValue string `json:"isolate_session_key_id_value"` LaunchRuntimeEvidenceDigest string `json:"launch_runtime_evidence_digest"` @@ -170,6 +200,7 @@ type isolateAttestationEvidenceDigestFields struct { IsolateID string `json:"isolate_id"` SessionID string `json:"session_id"` SessionNonce string `json:"session_nonce"` + LaunchContextDigest string `json:"launch_context_digest"` HandshakeTranscriptHash string `json:"handshake_transcript_hash"` IsolateSessionKeyIDValue string `json:"isolate_session_key_id_value"` LaunchRuntimeEvidenceDigest string `json:"launch_runtime_evidence_digest"` @@ -202,6 +233,7 @@ type isolateAttestationReplayIdentityFields struct { IsolateID string `json:"isolate_id"` SessionID string `json:"session_id"` SessionNonce string `json:"session_nonce"` + LaunchContextDigest string `json:"launch_context_digest"` HandshakeTranscriptHash string `json:"handshake_transcript_hash"` IsolateSessionKeyIDValue string `json:"isolate_session_key_id_value"` LaunchEvidenceDigest string `json:"launch_evidence_digest"` diff --git a/internal/launcherbackend/contract_types.go b/internal/launcherbackend/contract_types.go index b15fb35e..12f1f7e4 100644 --- a/internal/launcherbackend/contract_types.go +++ b/internal/launcherbackend/contract_types.go @@ -182,6 +182,18 @@ type SecureSessionSummary struct { TranscriptBinding string `json:"transcript_binding"` } +type RuntimeSecureSessionMaterial struct { + LaunchContext LaunchContext `json:"launch_context"` + HostHello HostHello `json:"host_hello"` + IsolateHello IsolateHello `json:"isolate_hello"` + SessionReady SessionReady `json:"session_ready"` +} + +type RuntimePostHandshakeMaterial struct { + SecureSession *RuntimeSecureSessionMaterial `json:"secure_session,omitempty"` + Attestation *PostHandshakeRuntimeAttestationInput `json:"attestation,omitempty"` +} + type SessionSecurityPosture struct { MutuallyAuthenticated bool `json:"mutually_authenticated"` Encrypted bool `json:"encrypted"` diff --git a/internal/launcherdaemon/container_controller_linux.go b/internal/launcherdaemon/container_controller_linux.go index e61218f8..41897600 100644 --- a/internal/launcherdaemon/container_controller_linux.go +++ b/internal/launcherdaemon/container_controller_linux.go @@ -14,22 +14,29 @@ import ( ) type ContainerControllerConfig struct { - WorkRoot string - Now func() time.Time + WorkRoot string + Now func() time.Time + RuntimePostHandshakeMaterialProvider func(launcherbackend.BackendLaunchSpec, launcherbackend.BackendLaunchReceipt) (*launcherbackend.RuntimePostHandshakeMaterial, error) } type containerController struct { - workRoot string - now func() time.Time - mu sync.RWMutex - instances map[string]InstanceState + workRoot string + now func() time.Time + runtimePostHandshakeMaterialProvider func(launcherbackend.BackendLaunchSpec, launcherbackend.BackendLaunchReceipt) (*launcherbackend.RuntimePostHandshakeMaterial, error) + mu sync.RWMutex + instances map[string]InstanceState } func NewContainerController(cfg ContainerControllerConfig) Controller { if cfg.Now == nil { cfg.Now = time.Now } - return &containerController{workRoot: strings.TrimSpace(cfg.WorkRoot), now: cfg.Now, instances: map[string]InstanceState{}} + return &containerController{ + workRoot: strings.TrimSpace(cfg.WorkRoot), + now: cfg.Now, + runtimePostHandshakeMaterialProvider: cfg.RuntimePostHandshakeMaterialProvider, + instances: map[string]InstanceState{}, + } } func (c *containerController) Launch(_ context.Context, spec launcherbackend.BackendLaunchSpec) (<-chan RuntimeUpdate, error) { @@ -114,32 +121,75 @@ func validateContainerLaunchSpec(spec launcherbackend.BackendLaunchSpec) (launch return hardening, nil } -func (c *containerController) storeLaunchedContainerInstance(ref InstanceRef) { +func (c *containerController) storeContainerInstanceState(ref InstanceRef, state launcherbackend.RuntimeLifecycleState, active bool, lastErr string) { c.mu.Lock() - c.instances[instanceKey(ref)] = InstanceState{Ref: ref, Active: true, LifecycleState: launcherbackend.RuntimeLifecycleState{BackendLifecycle: &launcherbackend.BackendLifecycleSnapshot{CurrentState: launcherbackend.BackendLifecycleStateActive, PreviousState: launcherbackend.BackendLifecycleStateBinding, TerminateBetweenSteps: true, TransitionCount: 3}}} + c.instances[instanceKey(ref)] = InstanceState{Ref: ref, Active: active, LifecycleState: state, LastError: lastErr} c.mu.Unlock() } func (c *containerController) buildContainerRuntimeUpdates(ref InstanceRef, spec launcherbackend.BackendLaunchSpec, hardening launcherbackend.AppliedHardeningPosture, admission launcherbackend.RuntimeAdmissionRecord, isolateID string, sessionID string, nonce string) <-chan RuntimeUpdate { - updates := make(chan RuntimeUpdate, 3) - receipt, err := containerLaunchReceipt(spec, admission, isolateID, sessionID, nonce, c.now()) + updates := make(chan RuntimeUpdate, 8) + receipt, err := containerLaunchReceipt(spec, admission, isolateID, sessionID, nonce) if err != nil { updates <- RuntimeUpdate{RunID: spec.RunID, Facts: &launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: launcherbackend.BackendLaunchReceipt{RunID: spec.RunID, StageID: spec.StageID, RoleInstanceID: spec.RoleInstanceID, BackendKind: launcherbackend.BackendKindContainer, IsolationAssuranceLevel: launcherbackend.IsolationAssuranceDegraded, LaunchFailureReasonCode: launcherbackend.BackendErrorCodeHandshakeFailed}, HardeningPosture: hardening}} close(updates) return updates } - c.storeLaunchedContainerInstance(ref) - facts := launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: receipt, HardeningPosture: hardening} - updates <- RuntimeUpdate{RunID: spec.RunID, Facts: &facts} - started := lifecycleUpdate(launcherbackend.BackendLifecycleStateStarted, launcherbackend.BackendLifecycleStateLaunching, 2, "") - active := lifecycleUpdate(launcherbackend.BackendLifecycleStateActive, launcherbackend.BackendLifecycleStateStarted, 3, "") - updates <- RuntimeUpdate{RunID: spec.RunID, Lifecycle: &started} - updates <- RuntimeUpdate{RunID: spec.RunID, Lifecycle: &active} + c.emitContainerLaunchProgress(ref, spec.RunID, receipt, hardening, updates) + material, err := c.containerRuntimePostHandshakeMaterial(spec, receipt) + if err != nil { + c.emitContainerHandshakeFailure(ref, spec.RunID, updates) + close(updates) + return updates + } + postHandshake, err := runtimePostHandshakeFactsUpdate(spec.RunID, receipt, admission, hardening, material) + if err != nil { + c.emitContainerHandshakeFailure(ref, spec.RunID, updates) + close(updates) + return updates + } + updates <- postHandshake + c.emitContainerActive(ref, spec.RunID, updates) close(updates) return updates } -func containerLaunchReceipt(spec launcherbackend.BackendLaunchSpec, admission launcherbackend.RuntimeAdmissionRecord, isolateID string, sessionID string, nonce string, now time.Time) (launcherbackend.BackendLaunchReceipt, error) { +func (c *containerController) emitContainerLaunchProgress(ref InstanceRef, runID string, receipt launcherbackend.BackendLaunchReceipt, hardening launcherbackend.AppliedHardeningPosture, updates chan<- RuntimeUpdate) { + launching := lifecycleUpdate(launcherbackend.BackendLifecycleStateLaunching, launcherbackend.BackendLifecycleStatePlanned, 1, "") + c.storeContainerInstanceState(ref, launching, true, "") + facts := launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: receipt, HardeningPosture: hardening} + updates <- RuntimeUpdate{RunID: runID, Facts: &facts} + started := lifecycleUpdate(launcherbackend.BackendLifecycleStateStarted, launcherbackend.BackendLifecycleStateLaunching, 2, "") + binding := lifecycleUpdate(launcherbackend.BackendLifecycleStateBinding, launcherbackend.BackendLifecycleStateStarted, 3, "") + c.storeContainerInstanceState(ref, started, true, "") + updates <- RuntimeUpdate{RunID: runID, Lifecycle: &started} + c.storeContainerInstanceState(ref, binding, true, "") + updates <- RuntimeUpdate{RunID: runID, Lifecycle: &binding} +} + +func (c *containerController) containerRuntimePostHandshakeMaterial(spec launcherbackend.BackendLaunchSpec, receipt launcherbackend.BackendLaunchReceipt) (*launcherbackend.RuntimePostHandshakeMaterial, error) { + if c.runtimePostHandshakeMaterialProvider == nil { + return nil, fmt.Errorf("runtime post-handshake material not provided") + } + return c.runtimePostHandshakeMaterialProvider(spec, receipt) +} + +func (c *containerController) emitContainerHandshakeFailure(ref InstanceRef, runID string, updates chan<- RuntimeUpdate) { + terminating := lifecycleUpdate(launcherbackend.BackendLifecycleStateTerminating, launcherbackend.BackendLifecycleStateBinding, 4, launcherbackend.BackendErrorCodeHandshakeFailed) + terminated := lifecycleUpdate(launcherbackend.BackendLifecycleStateTerminated, launcherbackend.BackendLifecycleStateTerminating, 5, launcherbackend.BackendErrorCodeHandshakeFailed) + c.storeContainerInstanceState(ref, terminating, false, launcherbackend.BackendErrorCodeHandshakeFailed) + updates <- RuntimeUpdate{RunID: runID, Lifecycle: &terminating} + c.storeContainerInstanceState(ref, terminated, false, launcherbackend.BackendErrorCodeHandshakeFailed) + updates <- RuntimeUpdate{RunID: runID, Lifecycle: &terminated} +} + +func (c *containerController) emitContainerActive(ref InstanceRef, runID string, updates chan<- RuntimeUpdate) { + active := lifecycleUpdate(launcherbackend.BackendLifecycleStateActive, launcherbackend.BackendLifecycleStateBinding, 4, "") + c.storeContainerInstanceState(ref, active, true, "") + updates <- RuntimeUpdate{RunID: runID, Lifecycle: &active} +} + +func containerLaunchReceipt(spec launcherbackend.BackendLaunchSpec, admission launcherbackend.RuntimeAdmissionRecord, isolateID string, sessionID string, nonce string) (launcherbackend.BackendLaunchReceipt, error) { sessionBinding, err := deriveRuntimeSessionBinding(spec, admission.DescriptorDigest, isolateID, sessionID, nonce) if err != nil { return launcherbackend.BackendLaunchReceipt{}, err @@ -173,9 +223,6 @@ func containerLaunchReceipt(spec launcherbackend.BackendLaunchSpec, admission la Lifecycle: &launcherbackend.BackendLifecycleSnapshot{CurrentState: launcherbackend.BackendLifecycleStateLaunching, TerminateBetweenSteps: true, TransitionCount: 1}, } populateRuntimeSessionBinding(&receipt, sessionBinding) - if err := applyTrustedRuntimeAttestation(&receipt, admission, now); err != nil { - return launcherbackend.BackendLaunchReceipt{}, err - } return receipt, nil } diff --git a/internal/launcherdaemon/container_controller_linux_test.go b/internal/launcherdaemon/container_controller_linux_test.go index 0bff82f7..bd04aa18 100644 --- a/internal/launcherdaemon/container_controller_linux_test.go +++ b/internal/launcherdaemon/container_controller_linux_test.go @@ -5,9 +5,9 @@ package launcherdaemon import ( "context" "os" + "slices" "strings" "testing" - "time" "github.com/runecode-ai/runecode/internal/launcherbackend" ) @@ -33,7 +33,7 @@ func TestContainerControllerLaunchUsesAdmittedRuntimeIdentityInReceipt(t *testin t.Skip("container controller requires rootless launcher execution") } workRoot, spec := admittedContainerSpecForReceiptTest(t) - controller := NewContainerController(ContainerControllerConfig{WorkRoot: workRoot}) + controller := NewContainerController(ContainerControllerConfig{WorkRoot: workRoot, RuntimePostHandshakeMaterialProvider: runtimePostHandshakeMaterialProviderForContainerTests}) updates, err := controller.Launch(context.Background(), spec) if err != nil { t.Fatalf("Launch returned error: %v", err) @@ -41,31 +41,198 @@ func TestContainerControllerLaunchUsesAdmittedRuntimeIdentityInReceipt(t *testin assertContainerLaunchReceiptUsesAdmittedRuntimeIdentity(t, updates, spec) } -func TestContainerControllerLaunchUsesInjectedClockForAttestationTimestamp(t *testing.T) { +func TestContainerControllerLaunchKeepsLaunchFactsNonAttestedUntilRuntimeUpdate(t *testing.T) { + updates := launchContainerControllerForTest(t, runtimePostHandshakeMaterialProviderForContainerTests) + first := requireFirstContainerFacts(t, updates) + assertLaunchFactsRemainPreHandshake(t, first) + postHandshake := requireLaterPostHandshakeFacts(t, updates) + assertPostHandshakeFactsCollected(t, postHandshake) +} + +func TestContainerControllerLaunchEmitsOrderedLaunchThenPostHandshakeFacts(t *testing.T) { + updates := launchContainerControllerForTest(t, runtimePostHandshakeMaterialProviderForContainerTests) + assertLaunchFactsRemainPreHandshake(t, requireFirstContainerFacts(t, updates)) + requireLaterPostHandshakeFacts(t, updates) +} + +func TestContainerControllerLaunchFailsClosedWithoutRuntimePostHandshakeMaterial(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("container controller requires rootless launcher execution") + } + workRoot, spec := admittedContainerSpecForReceiptTest(t) + controller := NewContainerController(ContainerControllerConfig{ + WorkRoot: workRoot, + RuntimePostHandshakeMaterialProvider: func(launcherbackend.BackendLaunchSpec, launcherbackend.BackendLaunchReceipt) (*launcherbackend.RuntimePostHandshakeMaterial, error) { + return nil, nil + }, + }) + updates, err := controller.Launch(context.Background(), spec) + if err != nil { + t.Fatalf("Launch returned error: %v", err) + } + lastLifecycle := launcherbackend.RuntimeLifecycleState{} + for update := range updates { + if update.Lifecycle != nil { + lastLifecycle = *update.Lifecycle + } + } + if lastLifecycle.BackendLifecycle == nil || lastLifecycle.BackendLifecycle.CurrentState != launcherbackend.BackendLifecycleStateTerminated { + t.Fatalf("final lifecycle state = %#v, want terminated", lastLifecycle.BackendLifecycle) + } + if got, want := lastLifecycle.LaunchFailureReasonCode, launcherbackend.BackendErrorCodeHandshakeFailed; got != want { + t.Fatalf("failure reason = %q, want %q", got, want) + } +} + +func TestContainerControllerLaunchFailsClosedWithoutRuntimePostHandshakeMaterialProvider(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("container controller requires rootless launcher execution") + } + workRoot, spec := admittedContainerSpecForReceiptTest(t) + controller := NewContainerController(ContainerControllerConfig{WorkRoot: workRoot}) + updates, err := controller.Launch(context.Background(), spec) + if err != nil { + t.Fatalf("Launch returned error: %v", err) + } + lastLifecycle := launcherbackend.RuntimeLifecycleState{} + for update := range updates { + if update.Lifecycle != nil { + lastLifecycle = *update.Lifecycle + } + } + if lastLifecycle.BackendLifecycle == nil || lastLifecycle.BackendLifecycle.CurrentState != launcherbackend.BackendLifecycleStateTerminated { + t.Fatalf("final lifecycle state = %#v, want terminated", lastLifecycle.BackendLifecycle) + } + if got, want := lastLifecycle.LaunchFailureReasonCode, launcherbackend.BackendErrorCodeHandshakeFailed; got != want { + t.Fatalf("failure reason = %q, want %q", got, want) + } +} + +func TestContainerControllerLaunchFailsClosedWhenRuntimeEvidenceClaimDigestIsInvalid(t *testing.T) { + updates := launchContainerControllerForTest(t, invalidContainerRuntimePostHandshakeMaterialProvider) + assertContainerTerminatedWithHandshakeFailure(t, updates) +} + +func launchContainerControllerForTest(t *testing.T, provider func(launcherbackend.BackendLaunchSpec, launcherbackend.BackendLaunchReceipt) (*launcherbackend.RuntimePostHandshakeMaterial, error)) <-chan RuntimeUpdate { + t.Helper() if os.Geteuid() == 0 { t.Skip("container controller requires rootless launcher execution") } workRoot, spec := admittedContainerSpecForReceiptTest(t) - attestedAt := time.Date(2026, time.February, 3, 4, 5, 6, 0, time.UTC) - controller := NewContainerController(ContainerControllerConfig{WorkRoot: workRoot, Now: func() time.Time { return attestedAt }}) + controller := NewContainerController(ContainerControllerConfig{WorkRoot: workRoot, RuntimePostHandshakeMaterialProvider: provider}) updates, err := controller.Launch(context.Background(), spec) if err != nil { t.Fatalf("Launch returned error: %v", err) } + return updates +} + +func requireFirstContainerFacts(t *testing.T, updates <-chan RuntimeUpdate) *launcherbackend.RuntimeFactsSnapshot { + t.Helper() first, ok := <-updates if !ok || first.Facts == nil { t.Fatal("first runtime update must include facts") } - if got, want := first.Facts.LaunchReceipt.AttestationVerificationTimestamp, attestedAt.Format(time.RFC3339); got != want { + return first.Facts +} + +func assertLaunchFactsRemainPreHandshake(t *testing.T, facts *launcherbackend.RuntimeFactsSnapshot) { + t.Helper() + if got, want := facts.LaunchReceipt.AttestationVerificationTimestamp, ""; got != want { t.Fatalf("attestation verification timestamp = %q, want %q", got, want) } + if facts.PostHandshakeAttestationInput != nil { + t.Fatal("post-handshake attestation input must not be present at launch-time facts") + } + if facts.LaunchReceipt.SessionSecurity != nil { + t.Fatal("session_security must not be present before runtime secure-session update") + } +} + +func requireLaterPostHandshakeFacts(t *testing.T, updates <-chan RuntimeUpdate) *launcherbackend.RuntimeFactsSnapshot { + t.Helper() + for update := range updates { + if update.Facts != nil && update.Facts.PostHandshakeAttestationInput != nil { + return update.Facts + } + } + t.Fatal("expected a later post-handshake facts update") + return nil +} + +func assertPostHandshakeFactsCollected(t *testing.T, facts *launcherbackend.RuntimeFactsSnapshot) { + t.Helper() + if facts.LaunchReceipt.SessionSecurity == nil { + t.Fatal("post-handshake facts must include validated session_security") + } + if !facts.PostHandshakeAttestationInput.RuntimeEvidenceCollected { + t.Fatal("post-handshake facts must report runtime_evidence_collected=true when runtime material is present") + } +} + +func assertContainerTerminatedWithHandshakeFailure(t *testing.T, updates <-chan RuntimeUpdate) { + t.Helper() + lastLifecycle := launcherbackend.RuntimeLifecycleState{} + for update := range updates { + if update.Lifecycle != nil { + lastLifecycle = *update.Lifecycle + } + } + if lastLifecycle.BackendLifecycle == nil || lastLifecycle.BackendLifecycle.CurrentState != launcherbackend.BackendLifecycleStateTerminated { + t.Fatalf("final lifecycle state = %#v, want terminated", lastLifecycle.BackendLifecycle) + } + if got, want := lastLifecycle.LaunchFailureReasonCode, launcherbackend.BackendErrorCodeHandshakeFailed; got != want { + t.Fatalf("failure reason = %q, want %q", got, want) + } +} + +func invalidContainerRuntimePostHandshakeMaterialProvider(spec launcherbackend.BackendLaunchSpec, receipt launcherbackend.BackendLaunchReceipt) (*launcherbackend.RuntimePostHandshakeMaterial, error) { + handshakeTuple, _, err := secureSessionHandshakeTuple(spec, receipt) + if err != nil { + return nil, err + } + return &launcherbackend.RuntimePostHandshakeMaterial{ + SecureSession: &launcherbackend.RuntimeSecureSessionMaterial{ + LaunchContext: handshakeTuple.launchContext, + HostHello: handshakeTuple.host, + IsolateHello: handshakeTuple.isolate, + SessionReady: handshakeTuple.ready, + }, + Attestation: &launcherbackend.PostHandshakeRuntimeAttestationInput{ + RuntimeEvidenceCollected: true, + AttestationSourceKind: launcherbackend.AttestationSourceKindTrustedRuntime, + MeasurementProfile: launcherbackend.MeasurementProfileContainerImageV1, + EvidenceClaimsDigest: "sha256:" + strings.Repeat("f", 64), + }, + }, nil +} + +func TestLaunchReceiptBuildersRequireSecureSessionValidationBeforeAttestedPosture(t *testing.T) { + for _, tc := range launchReceiptBuilderTests() { + t.Run(tc.name, func(t *testing.T) { + assertLaunchReceiptRequiresSecureSessionValidationBeforeAttestedPosture(t, tc.spec, tc.build) + }) + } +} + +func TestLaunchReceiptBuildersRequirePostHandshakeEvidenceForAttestationSuccess(t *testing.T) { + for _, tc := range launchReceiptBuilderTests() { + t.Run(tc.name, func(t *testing.T) { + evidence := buildLaunchReceiptEvidenceForTest(t, tc.spec, tc.build, func(facts *launcherbackend.RuntimeFactsSnapshot) { + facts.PostHandshakeAttestationInput = nil + }) + assertInvalidAttestationEvidence(t, evidence, "attestation_post_handshake_input_required") + }) + } } -func TestLaunchReceiptBuildersUseDerivedRuntimeSessionBinding(t *testing.T) { - attestedAt := time.Date(2026, time.March, 4, 5, 6, 7, 0, time.UTC) - for _, tc := range launchReceiptBuilderTests(attestedAt) { +func TestLaunchReceiptBuildersRequireSecureSessionValidationForAttestationSuccess(t *testing.T) { + for _, tc := range launchReceiptBuilderTests() { t.Run(tc.name, func(t *testing.T) { - assertLaunchReceiptUsesDerivedRuntimeSessionBinding(t, tc.spec, tc.build) + evidence := buildLaunchReceiptEvidenceForTest(t, tc.spec, tc.build, func(facts *launcherbackend.RuntimeFactsSnapshot) { + facts.LaunchReceipt.SessionSecurity = nil + }) + assertInvalidAttestationEvidence(t, evidence, "attestation_session_validation_required") }) } } @@ -92,65 +259,189 @@ func TestMakeRuntimeIdentityUsesDistinctFullSessionIdentifier(t *testing.T) { } } -func assertLaunchReceiptUsesDerivedRuntimeSessionBinding(t *testing.T, spec launcherbackend.BackendLaunchSpec, build func(launcherbackend.BackendLaunchSpec, launcherbackend.RuntimeAdmissionRecord) (launcherbackend.BackendLaunchReceipt, error)) { +func assertLaunchReceiptRequiresSecureSessionValidationBeforeAttestedPosture(t *testing.T, spec launcherbackend.BackendLaunchSpec, build func(launcherbackend.BackendLaunchSpec, launcherbackend.RuntimeAdmissionRecord) (launcherbackend.BackendLaunchReceipt, *launcherbackend.PostHandshakeRuntimeAttestationInput, error)) { + t.Helper() + admission, err := launcherbackend.NewRuntimeAdmissionRecord(spec.Image) + if err != nil { + t.Fatalf("NewRuntimeAdmissionRecord returned error: %v", err) + } + receipt, attestationInput, err := build(spec, admission) + if err != nil { + t.Fatalf("build receipt returned error: %v", err) + } + if receipt.ProvisioningPosture != launcherbackend.ProvisioningPostureTOFU { + t.Fatalf("provisioning posture = %q, want %q", receipt.ProvisioningPosture, launcherbackend.ProvisioningPostureTOFU) + } + if receipt.IsolateID != "isolate-shared" || receipt.SessionID != "session-shared" || receipt.SessionNonce != strings.Repeat("a", 32) { + t.Fatalf("receipt session tuple = (%q, %q, %q), want (%q, %q, %q)", receipt.IsolateID, receipt.SessionID, receipt.SessionNonce, "isolate-shared", "session-shared", strings.Repeat("a", 32)) + } + if receipt.LaunchContextDigest == "" || receipt.HandshakeTranscriptHash == "" || receipt.IsolateSessionKeyIDValue == "" { + t.Fatal("secure-session validated binding fields must be populated") + } + if receipt.SessionSecurity == nil { + t.Fatal("session_security must be populated after secure-session validation") + } + facts := launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: receipt, PostHandshakeAttestationInput: attestationInput, HardeningPosture: launcherbackend.AppliedHardeningPosture{Requested: launcherbackend.HardeningRequestedHardened, Effective: launcherbackend.HardeningEffectiveHardened}} + evidence, _, err := launcherbackend.SplitRuntimeFactsEvidenceAndLifecycle(facts) + if err != nil { + t.Fatalf("SplitRuntimeFactsEvidenceAndLifecycle returned error: %v", err) + } + if evidence.Launch.ProvisioningPosture != launcherbackend.ProvisioningPostureTOFU { + t.Fatalf("evidence launch provisioning posture = %q, want %q", evidence.Launch.ProvisioningPosture, launcherbackend.ProvisioningPostureTOFU) + } +} + +func buildLaunchReceiptEvidenceForTest(t *testing.T, spec launcherbackend.BackendLaunchSpec, build func(launcherbackend.BackendLaunchSpec, launcherbackend.RuntimeAdmissionRecord) (launcherbackend.BackendLaunchReceipt, *launcherbackend.PostHandshakeRuntimeAttestationInput, error), mutate func(*launcherbackend.RuntimeFactsSnapshot)) launcherbackend.RuntimeEvidenceSnapshot { t.Helper() admission, err := launcherbackend.NewRuntimeAdmissionRecord(spec.Image) if err != nil { t.Fatalf("NewRuntimeAdmissionRecord returned error: %v", err) } - receipt, err := build(spec, admission) + receipt, attestationInput, err := build(spec, admission) if err != nil { t.Fatalf("build receipt returned error: %v", err) } - binding := mustDeriveRuntimeSessionBinding(t, spec, admission.DescriptorDigest, "isolate-shared", "session-shared", strings.Repeat("a", 32)) - if receipt.ProvisioningPosture != launcherbackend.ProvisioningPostureAttested { - t.Fatalf("provisioning posture = %q, want %q", receipt.ProvisioningPosture, launcherbackend.ProvisioningPostureAttested) + facts := launcherbackend.RuntimeFactsSnapshot{ + LaunchReceipt: receipt, + PostHandshakeAttestationInput: attestationInput, + HardeningPosture: launcherbackend.AppliedHardeningPosture{Requested: launcherbackend.HardeningRequestedHardened, Effective: launcherbackend.HardeningEffectiveHardened}, + } + if mutate != nil { + mutate(&facts) + } + evidence, _, err := launcherbackend.SplitRuntimeFactsEvidenceAndLifecycle(facts) + if err != nil { + t.Fatalf("SplitRuntimeFactsEvidenceAndLifecycle returned error: %v", err) } - assertReceiptSessionBindingMatches(t, receipt, binding) + return evidence } -func assertReceiptSessionBindingMatches(t *testing.T, receipt launcherbackend.BackendLaunchReceipt, binding runtimeSessionBinding) { +func assertInvalidAttestationEvidence(t *testing.T, evidence launcherbackend.RuntimeEvidenceSnapshot, expectedReason string) { t.Helper() - actual := runtimeSessionBinding{ - IsolateID: receipt.IsolateID, - SessionID: receipt.SessionID, - SessionNonce: receipt.SessionNonce, - LaunchContextDigest: receipt.LaunchContextDigest, - HandshakeTranscriptHash: receipt.HandshakeTranscriptHash, - IsolateSessionKeyIDValue: receipt.IsolateSessionKeyIDValue, + if evidence.Attestation != nil { + t.Fatalf("attestation evidence = %#v, want nil for invalid attestation", evidence.Attestation) + } + if evidence.AttestationVerification == nil { + t.Fatal("attestation verification missing") + } + if got, want := evidence.AttestationVerification.VerificationResult, launcherbackend.AttestationVerificationResultInvalid; got != want { + t.Fatalf("verification result = %q, want %q", got, want) + } + if !slices.Contains(evidence.AttestationVerification.ReasonCodes, expectedReason) { + t.Fatalf("reason codes = %v, want %s", evidence.AttestationVerification.ReasonCodes, expectedReason) } - if actual != binding { - t.Fatalf("receipt session binding fields = %+v, want %+v", actual, binding) + attestationPosture, _ := launcherbackend.DeriveAttestationPostureFromEvidence(evidence) + if attestationPosture == launcherbackend.AttestationPostureValid { + t.Fatalf("attestation posture = %q, want not valid", attestationPosture) } } -func launchReceiptBuilderTests(attestedAt time.Time) []struct { +func launchReceiptBuilderTests() []struct { name string spec launcherbackend.BackendLaunchSpec - build func(launcherbackend.BackendLaunchSpec, launcherbackend.RuntimeAdmissionRecord) (launcherbackend.BackendLaunchReceipt, error) + build func(launcherbackend.BackendLaunchSpec, launcherbackend.RuntimeAdmissionRecord) (launcherbackend.BackendLaunchReceipt, *launcherbackend.PostHandshakeRuntimeAttestationInput, error) } { return []struct { name string spec launcherbackend.BackendLaunchSpec - build func(launcherbackend.BackendLaunchSpec, launcherbackend.RuntimeAdmissionRecord) (launcherbackend.BackendLaunchReceipt, error) + build func(launcherbackend.BackendLaunchSpec, launcherbackend.RuntimeAdmissionRecord) (launcherbackend.BackendLaunchReceipt, *launcherbackend.PostHandshakeRuntimeAttestationInput, error) }{ { name: "microvm", spec: validSpecForTests(), - build: func(spec launcherbackend.BackendLaunchSpec, admission launcherbackend.RuntimeAdmissionRecord) (launcherbackend.BackendLaunchReceipt, error) { - return buildLaunchReceipt(spec, admission, "isolate-shared", "session-shared", strings.Repeat("a", 32), "9.0.0", "qemu-system-x86_64 9.0.0", nil, attestedAt) + build: func(spec launcherbackend.BackendLaunchSpec, admission launcherbackend.RuntimeAdmissionRecord) (launcherbackend.BackendLaunchReceipt, *launcherbackend.PostHandshakeRuntimeAttestationInput, error) { + receipt, err := buildLaunchReceipt(spec, admission, "isolate-shared", "session-shared", strings.Repeat("a", 32), "9.0.0", "qemu-system-x86_64 9.0.0", nil) + if err != nil { + return launcherbackend.BackendLaunchReceipt{}, nil, err + } + return validatedReceiptWithPostHandshakeProgress(spec, admission, receipt) }, }, { name: "container", spec: validContainerSpecForTests(), - build: func(spec launcherbackend.BackendLaunchSpec, admission launcherbackend.RuntimeAdmissionRecord) (launcherbackend.BackendLaunchReceipt, error) { - return containerLaunchReceipt(spec, admission, "isolate-shared", "session-shared", strings.Repeat("a", 32), attestedAt) + build: func(spec launcherbackend.BackendLaunchSpec, admission launcherbackend.RuntimeAdmissionRecord) (launcherbackend.BackendLaunchReceipt, *launcherbackend.PostHandshakeRuntimeAttestationInput, error) { + receipt, err := containerLaunchReceipt(spec, admission, "isolate-shared", "session-shared", strings.Repeat("a", 32)) + if err != nil { + return launcherbackend.BackendLaunchReceipt{}, nil, err + } + return validatedReceiptWithPostHandshakeProgress(spec, admission, receipt) }, }, } } +func validatedReceiptWithPostHandshakeProgress(spec launcherbackend.BackendLaunchSpec, admission launcherbackend.RuntimeAdmissionRecord, receipt launcherbackend.BackendLaunchReceipt) (launcherbackend.BackendLaunchReceipt, *launcherbackend.PostHandshakeRuntimeAttestationInput, error) { + secureSession, err := runtimeSecureSessionMaterialForBuilder(spec, receipt) + if err != nil { + return launcherbackend.BackendLaunchReceipt{}, nil, err + } + summary, launchContextDigest, err := validateSecureSessionAndBuildSummary(receipt, secureSession) + if err != nil { + return launcherbackend.BackendLaunchReceipt{}, nil, err + } + if err := recordValidatedSecureSession(&receipt, summary, launchContextDigest); err != nil { + return launcherbackend.BackendLaunchReceipt{}, nil, err + } + input, err := buildPostHandshakeAttestationProgress(receipt, admission) + if err != nil { + return launcherbackend.BackendLaunchReceipt{}, nil, err + } + if err := recordPostHandshakeAttestationProgress(&receipt, input); err != nil { + return launcherbackend.BackendLaunchReceipt{}, nil, err + } + return receipt, input, nil +} + +func runtimeSecureSessionMaterialForBuilder(spec launcherbackend.BackendLaunchSpec, receipt launcherbackend.BackendLaunchReceipt) (*launcherbackend.RuntimeSecureSessionMaterial, error) { + handshakeTuple, _, err := secureSessionHandshakeTuple(spec, receipt) + if err != nil { + return nil, err + } + return &launcherbackend.RuntimeSecureSessionMaterial{ + LaunchContext: handshakeTuple.launchContext, + HostHello: handshakeTuple.host, + IsolateHello: handshakeTuple.isolate, + SessionReady: handshakeTuple.ready, + }, nil +} + +func runtimePostHandshakeMaterialProviderForContainerTests(spec launcherbackend.BackendLaunchSpec, receipt launcherbackend.BackendLaunchReceipt) (*launcherbackend.RuntimePostHandshakeMaterial, error) { + handshakeTuple, _, err := secureSessionHandshakeTuple(spec, receipt) + if err != nil { + return nil, err + } + expectedMeasurementDigests, err := launcherbackend.DeriveExpectedMeasurementDigests(launcherbackend.MeasurementProfileContainerImageV1, receipt.RuntimeImageBootProfile, receipt.BootComponentDigestByName) + if err != nil { + return nil, err + } + return &launcherbackend.RuntimePostHandshakeMaterial{ + SecureSession: &launcherbackend.RuntimeSecureSessionMaterial{ + LaunchContext: handshakeTuple.launchContext, + HostHello: handshakeTuple.host, + IsolateHello: handshakeTuple.isolate, + SessionReady: handshakeTuple.ready, + }, + Attestation: &launcherbackend.PostHandshakeRuntimeAttestationInput{ + RunID: receipt.RunID, + IsolateID: receipt.IsolateID, + SessionID: receipt.SessionID, + SessionNonce: receipt.SessionNonce, + LaunchContextDigest: handshakeTuple.launchContext.LaunchContextDigest, + HandshakeTranscriptHash: handshakeTuple.ready.HandshakeTranscriptHash, + IsolateSessionKeyIDValue: handshakeTuple.ready.IsolateKeyIDValue, + RuntimeImageDescriptorDigest: receipt.RuntimeImageDescriptorDigest, + RuntimeImageBootProfile: receipt.RuntimeImageBootProfile, + RuntimeEvidenceCollected: true, + AttestationSourceKind: launcherbackend.AttestationSourceKindTrustedRuntime, + MeasurementProfile: launcherbackend.MeasurementProfileContainerImageV1, + FreshnessMaterial: []string{"session_nonce"}, + FreshnessBindingClaims: []string{"session_nonce", "handshake_transcript_hash", "launch_context_digest"}, + EvidenceClaimsDigest: expectedMeasurementDigests[0], + }, + }, nil +} + func admittedContainerSpecForReceiptTest(t *testing.T) (string, launcherbackend.BackendLaunchSpec) { t.Helper() workRoot := t.TempDir() diff --git a/internal/launcherdaemon/container_controller_unsupported.go b/internal/launcherdaemon/container_controller_unsupported.go index 71b89405..cd9fb461 100644 --- a/internal/launcherdaemon/container_controller_unsupported.go +++ b/internal/launcherdaemon/container_controller_unsupported.go @@ -11,8 +11,9 @@ import ( ) type ContainerControllerConfig struct { - WorkRoot string - Now func() time.Time + WorkRoot string + Now func() time.Time + RuntimePostHandshakeMaterialProvider func(launcherbackend.BackendLaunchSpec, launcherbackend.BackendLaunchReceipt) (*launcherbackend.RuntimePostHandshakeMaterial, error) } type unsupportedContainerController struct{} diff --git a/internal/launcherdaemon/qemu_controller_linux.go b/internal/launcherdaemon/qemu_controller_linux.go index eda449a2..e1d76eca 100644 --- a/internal/launcherdaemon/qemu_controller_linux.go +++ b/internal/launcherdaemon/qemu_controller_linux.go @@ -4,7 +4,6 @@ package launcherdaemon import ( "context" - "io" "os" "os/exec" "strings" @@ -18,10 +17,11 @@ import ( const helloWorldToken = "RUNE_HELLO_WORLD" type QEMUControllerConfig struct { - QEMUBinary string - KernelPath string - WorkRoot string - Now func() time.Time + QEMUBinary string + KernelPath string + WorkRoot string + Now func() time.Time + RuntimePostHandshakeMaterialProvider func(launcherbackend.BackendLaunchSpec, launcherbackend.BackendLaunchReceipt) (*launcherbackend.RuntimePostHandshakeMaterial, error) } type qemuController struct { @@ -51,6 +51,9 @@ func NewQEMUController(cfg QEMUControllerConfig) Controller { if cfg.Now == nil { cfg.Now = time.Now } + if cfg.RuntimePostHandshakeMaterialProvider == nil { + cfg.RuntimePostHandshakeMaterialProvider = defaultQEMURuntimePostHandshakeMaterialProvider + } return &qemuController{cfg: cfg, instances: map[string]*qemuInstance{}} } @@ -60,7 +63,7 @@ func (c *qemuController) Launch(ctx context.Context, spec launcherbackend.Backen return nil, err } instance := c.registerLaunchState(spec, launchState) - go c.monitorInstance(context.Background(), instance, spec, launchState.stdout, launchState.hardening, launchState.receipt) + go c.monitorInstance(context.Background(), instance, launchState) return instance.updates, nil } @@ -97,75 +100,17 @@ func (c *qemuController) Shutdown(_ context.Context) error { } type preparedLaunchState struct { - stdout io.Reader + stdout launchStateStdout launchDir string receipt launcherbackend.BackendLaunchReceipt hardening launcherbackend.AppliedHardeningPosture - cmd *exec.Cmd + admission launcherbackend.RuntimeAdmissionRecord + material *launcherbackend.RuntimePostHandshakeMaterial + spec launcherbackend.BackendLaunchSpec + cmd launchStateCmd cancel context.CancelFunc } -func (c *qemuController) prepareLaunchState(ctx context.Context, spec launcherbackend.BackendLaunchSpec) (preparedLaunchState, error) { - if err := ctx.Err(); err != nil { - return preparedLaunchState{}, err - } - if err := c.validateLaunchPrereqs(spec); err != nil { - return preparedLaunchState{}, err - } - qemuPath := strings.TrimSpace(c.cfg.QEMUBinary) - admittedImage, launchDir, kernelPath, initrdPath, err := c.prepareLaunchAssets(ctx, qemuPath, spec) - if err != nil { - return preparedLaunchState{}, err - } - isoID, sessionID, nonce, err := makeRuntimeIdentity(spec.RunID) - if err != nil { - return preparedLaunchState{}, backendError(launcherbackend.BackendErrorCodeHypervisorLaunchFailed, "failed to generate runtime identity") - } - qemuVersion, qemuBuild := detectQEMUProvenance(qemuPath) - cmd, stdout, cancel, err := c.startQEMUProcess(ctx, qemuPath, kernelPath, initrdPath, spec.ResourceLimits) - if err != nil { - return preparedLaunchState{}, err - } - receipt, err := buildLaunchReceipt(spec, admittedImage.admissionRecord, isoID, sessionID, nonce, qemuVersion, qemuBuild, admittedImage.cacheEvidence, c.cfg.Now()) - if err != nil { - cancel() - return preparedLaunchState{}, backendError(launcherbackend.BackendErrorCodeHandshakeFailed, err.Error()) - } - return preparedLaunchState{ - stdout: stdout, - launchDir: launchDir, - receipt: receipt, - hardening: buildHardeningPosture(), - cmd: cmd, - cancel: cancel, - }, nil -} - -func (c *qemuController) prepareLaunchAssets(ctx context.Context, qemuPath string, spec launcherbackend.BackendLaunchSpec) (admittedRuntimeImage, string, string, string, error) { - admittedImage, err := admitRuntimeImage(c.cfg.WorkRoot, spec.Image) - if err != nil { - return admittedRuntimeImage{}, "", "", "", err - } - launchDir, err := c.prepareLaunchDir(spec) - if err != nil { - return admittedRuntimeImage{}, "", "", "", backendError(launcherbackend.BackendErrorCodeAttachmentPlanInvalid, "failed to materialize attachments") - } - keepLaunchDir := false - defer func() { - if !keepLaunchDir { - _ = os.RemoveAll(launchDir) - } - }() - if err := ctx.Err(); err != nil { - return admittedRuntimeImage{}, "", "", "", err - } - if err := verifyRuntimeToolchainArtifact(qemuPath, admittedImage.toolchain); err != nil { - return admittedRuntimeImage{}, "", "", "", backendError(launcherbackend.BackendErrorCodeImageDescriptorSignatureMismatch, err.Error()) - } - keepLaunchDir = true - return admittedImage, launchDir, admittedImage.componentPaths["kernel"], admittedImage.componentPaths["initrd"], nil -} - func (c *qemuController) validateLaunchPrereqs(spec launcherbackend.BackendLaunchSpec) error { if err := spec.Validate(); err != nil { return err @@ -185,38 +130,6 @@ func (c *qemuController) validateLaunchPrereqs(spec launcherbackend.BackendLaunc return nil } -func (c *qemuController) startQEMUProcess(ctx context.Context, qemuPath, kernelPath, initrdPath string, limits launcherbackend.BackendResourceLimits) (*exec.Cmd, io.Reader, context.CancelFunc, error) { - if err := ctx.Err(); err != nil { - return nil, nil, nil, err - } - argv := buildQEMUArgv(qemuPath, kernelPath, initrdPath, limits) - cmd := exec.Command(argv[0], argv[1:]...) - launchCancel := func() { - if cmd.Process != nil { - _ = cmd.Process.Kill() - } - } - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGKILL} - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, nil, nil, backendError(launcherbackend.BackendErrorCodeHypervisorLaunchFailed, "failed to prepare qemu output stream") - } - cmd.Stderr = cmd.Stdout - if err := cmd.Start(); err != nil { - launchCancel() - if strings.Contains(strings.ToLower(err.Error()), "kvm") { - return nil, nil, nil, backendError(launcherbackend.BackendErrorCodeAccelerationUnavailable, "kvm initialization failed") - } - return nil, nil, nil, backendError(launcherbackend.BackendErrorCodeHypervisorLaunchFailed, "qemu launch failed") - } - if err := ctx.Err(); err != nil { - launchCancel() - _ = cmd.Process.Kill() - return nil, nil, nil, err - } - return cmd, stdout, launchCancel, nil -} - func (c *qemuController) registerLaunchState(spec launcherbackend.BackendLaunchSpec, launchState preparedLaunchState) *qemuInstance { ref := InstanceRef{RunID: spec.RunID, StageID: spec.StageID, RoleInstanceID: spec.RoleInstanceID} updates := make(chan RuntimeUpdate, 8) @@ -242,11 +155,11 @@ func (c *qemuController) registerLaunchState(spec launcherbackend.BackendLaunchS } updates <- RuntimeUpdate{RunID: spec.RunID, Facts: &launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: launchState.receipt, HardeningPosture: launchState.hardening}} started := lifecycleUpdate(launcherbackend.BackendLifecycleStateStarted, launcherbackend.BackendLifecycleStateLaunching, 2, "") - active := lifecycleUpdate(launcherbackend.BackendLifecycleStateActive, launcherbackend.BackendLifecycleStateStarted, 3, "") + binding := lifecycleUpdate(launcherbackend.BackendLifecycleStateBinding, launcherbackend.BackendLifecycleStateStarted, 3, "") updates <- RuntimeUpdate{RunID: spec.RunID, Lifecycle: &started} - updates <- RuntimeUpdate{RunID: spec.RunID, Lifecycle: &active} + updates <- RuntimeUpdate{RunID: spec.RunID, Lifecycle: &binding} c.mu.Lock() - instance.state.LifecycleState = active + instance.state.LifecycleState = binding c.mu.Unlock() return instance } @@ -264,10 +177,5 @@ func (c *qemuController) terminateInstance(inst *qemuInstance) { func (c *qemuController) instanceByRef(ref InstanceRef) *qemuInstance { c.mu.RLock() defer c.mu.RUnlock() - if ref.RunID == "" && len(c.instances) == 1 { - for _, inst := range c.instances { - return inst - } - } return c.instances[instanceKey(ref)] } diff --git a/internal/launcherdaemon/qemu_controller_linux_test.go b/internal/launcherdaemon/qemu_controller_linux_test.go index 055afc7d..4d30b0da 100644 --- a/internal/launcherdaemon/qemu_controller_linux_test.go +++ b/internal/launcherdaemon/qemu_controller_linux_test.go @@ -40,40 +40,70 @@ func TestQEMUPrepareLaunchAssetsSurfacesToolchainVerificationFailure(t *testing. } } -func TestQEMULaunchReceiptCarriesTrustedRuntimeAttestation(t *testing.T) { +func TestQEMULaunchReceiptFailsClosedWithoutRuntimeCollectedAttestationEvidence(t *testing.T) { + receipt, attestationInput, evidence := qemuRuntimeAttestationEvidenceWithoutCollection(t) + if receipt.ProvisioningPosture != launcherbackend.ProvisioningPostureTOFU { + t.Fatalf("provisioning posture = %q, want %q", receipt.ProvisioningPosture, launcherbackend.ProvisioningPostureTOFU) + } + if evidence.Attestation != nil { + t.Fatalf("attestation evidence = %#v, want nil until runtime-side evidence is collected", evidence.Attestation) + } + assertQEMUAttestationVerificationInvalid(t, evidence) + if !strings.Contains(strings.Join(evidence.AttestationVerification.ReasonCodes, ","), "attestation_runtime_evidence_required") { + t.Fatalf("reason codes = %v, want attestation_runtime_evidence_required", evidence.AttestationVerification.ReasonCodes) + } + if evidence.Launch.ProvisioningPosture != launcherbackend.ProvisioningPostureTOFU { + t.Fatalf("evidence launch provisioning posture = %q, want %q", evidence.Launch.ProvisioningPosture, launcherbackend.ProvisioningPostureTOFU) + } + if got, want := receipt.AttestationVerificationTimestamp, ""; got != want { + t.Fatalf("attestation verification timestamp = %q, want %q", got, want) + } + if attestationInput == nil { + t.Fatal("post-handshake attestation input missing") + } + if got, want := attestationInput.VerificationTimestamp, ""; got != want { + t.Fatalf("post-handshake verification timestamp = %q, want %q", got, want) + } +} + +func qemuRuntimeAttestationEvidenceWithoutCollection(t *testing.T) (launcherbackend.BackendLaunchReceipt, *launcherbackend.PostHandshakeRuntimeAttestationInput, launcherbackend.RuntimeEvidenceSnapshot) { + t.Helper() _, _, spec := qemuToolchainVerificationLaunchSpecForTests(t) admission, err := launcherbackend.NewRuntimeAdmissionRecord(spec.Image) if err != nil { t.Fatalf("NewRuntimeAdmissionRecord returned error: %v", err) } - attestedAt := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - - receipt, err := buildLaunchReceipt(spec, admission, "isolate-1", "session-1", strings.Repeat("a", 32), "9.0.0", "qemu-system-x86_64 9.0.0", nil, attestedAt) + receipt, err := buildLaunchReceipt(spec, admission, "isolate-1", "session-1", strings.Repeat("a", 32), "9.0.0", "qemu-system-x86_64 9.0.0", nil) if err != nil { t.Fatalf("buildLaunchReceipt returned error: %v", err) } - facts := launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: receipt, HardeningPosture: buildHardeningPosture()} - evidence, _, err := launcherbackend.SplitRuntimeFactsEvidenceAndLifecycle(facts) + secureSession := mustRuntimeSecureSessionMaterialForTests(t, spec, receipt) + summary, launchContextDigest, err := validateSecureSessionAndBuildSummary(receipt, secureSession) if err != nil { - t.Fatalf("SplitRuntimeFactsEvidenceAndLifecycle returned error: %v", err) + t.Fatalf("validateSecureSessionAndBuildSummary returned error: %v", err) } - if receipt.ProvisioningPosture != launcherbackend.ProvisioningPostureAttested { - t.Fatalf("provisioning posture = %q, want %q", receipt.ProvisioningPosture, launcherbackend.ProvisioningPostureAttested) + if err := recordValidatedSecureSession(&receipt, summary, launchContextDigest); err != nil { + t.Fatalf("recordValidatedSecureSession returned error: %v", err) } - if evidence.Attestation == nil { - t.Fatal("attestation evidence missing") + attestationInput, err := buildPostHandshakeAttestationProgress(receipt, admission) + if err != nil { + t.Fatalf("buildPostHandshakeAttestationProgress returned error: %v", err) } + facts := launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: receipt, PostHandshakeAttestationInput: attestationInput, HardeningPosture: buildHardeningPosture()} + evidence, _, err := launcherbackend.SplitRuntimeFactsEvidenceAndLifecycle(facts) + if err != nil { + t.Fatalf("SplitRuntimeFactsEvidenceAndLifecycle returned error: %v", err) + } + return receipt, attestationInput, evidence +} + +func assertQEMUAttestationVerificationInvalid(t *testing.T, evidence launcherbackend.RuntimeEvidenceSnapshot) { + t.Helper() if evidence.AttestationVerification == nil { t.Fatal("attestation verification missing") } - if evidence.AttestationVerification.VerificationResult != launcherbackend.AttestationVerificationResultValid { - t.Fatalf("verification result = %q, want %q", evidence.AttestationVerification.VerificationResult, launcherbackend.AttestationVerificationResultValid) - } - if evidence.AttestationVerification.ReplayVerdict != launcherbackend.AttestationReplayVerdictOriginal { - t.Fatalf("replay verdict = %q, want %q", evidence.AttestationVerification.ReplayVerdict, launcherbackend.AttestationReplayVerdictOriginal) - } - if got, want := receipt.AttestationVerificationTimestamp, attestedAt.Format(time.RFC3339); got != want { - t.Fatalf("attestation verification timestamp = %q, want %q", got, want) + if evidence.AttestationVerification.VerificationResult != launcherbackend.AttestationVerificationResultInvalid { + t.Fatalf("verification result = %q, want %q", evidence.AttestationVerification.VerificationResult, launcherbackend.AttestationVerificationResultInvalid) } } @@ -83,20 +113,149 @@ func TestApplyTrustedRuntimeAttestationFailsClosedWithoutLaunchContextDigest(t * if err != nil { t.Fatalf("NewRuntimeAdmissionRecord returned error: %v", err) } - attestedAt := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - receipt, err := buildLaunchReceipt(spec, admission, "isolate-1", "session-1", strings.Repeat("a", 32), "9.0.0", "qemu-system-x86_64 9.0.0", nil, attestedAt) + receipt, err := buildLaunchReceipt(spec, admission, "isolate-1", "session-1", strings.Repeat("a", 32), "9.0.0", "qemu-system-x86_64 9.0.0", nil) if err != nil { t.Fatalf("buildLaunchReceipt returned error: %v", err) } + secureSession := mustRuntimeSecureSessionMaterialForTests(t, spec, receipt) + summary, launchContextDigest, err := validateSecureSessionAndBuildSummary(receipt, secureSession) + if err != nil { + t.Fatalf("validateSecureSessionAndBuildSummary returned error: %v", err) + } + if err := recordValidatedSecureSession(&receipt, summary, launchContextDigest); err != nil { + t.Fatalf("recordValidatedSecureSession returned error: %v", err) + } receipt.LaunchContextDigest = "" - - err = applyTrustedRuntimeAttestation(&receipt, admission, attestedAt) + _, err = buildPostHandshakeAttestationProgress(receipt, admission) if err == nil { - t.Fatal("applyTrustedRuntimeAttestation expected missing launch context digest error") + t.Fatal("buildPostHandshakeAttestationProgress expected missing launch context digest error") } if !strings.Contains(err.Error(), "session binding is required before attestation") { - t.Fatalf("applyTrustedRuntimeAttestation error = %q, want session binding failure", err.Error()) + t.Fatalf("buildPostHandshakeAttestationProgress error = %q, want session binding failure", err.Error()) + } +} + +func TestQEMURuntimePostHandshakeUpdateRequiresRuntimeProducedSecureSessionMaterial(t *testing.T) { + spec, admission, receipt := runtimeAttestationReceiptFixtureForValidation(t) + controller := &qemuController{cfg: QEMUControllerConfig{}, instances: map[string]*qemuInstance{}} + _, err := controller.runtimePostHandshakeUpdate(preparedLaunchState{ + spec: spec, + receipt: receipt, + admission: admission, + hardening: buildHardeningPosture(), + material: nil, + }) + if err == nil { + t.Fatal("runtimePostHandshakeUpdate expected missing runtime secure-session material error") + } + if !strings.Contains(err.Error(), launcherbackend.BackendErrorCodeHandshakeFailed) { + t.Fatalf("runtimePostHandshakeUpdate error = %q, want handshake failure", err.Error()) + } +} + +func TestQEMURuntimePostHandshakeUpdateRuntimeEvidenceCollectedTrueOnlyWithConcreteEvidence(t *testing.T) { + spec, admission, receipt := runtimeAttestationReceiptFixtureForValidation(t) + secureSession := mustRuntimeSecureSessionMaterialForTests(t, spec, receipt) + controller := &qemuController{cfg: QEMUControllerConfig{}, instances: map[string]*qemuInstance{}} + + updateWithoutEvidence, err := controller.runtimePostHandshakeUpdate(qemuPreparedStateForEvidenceTest(spec, admission, receipt, secureSession, nil)) + assertQEMURuntimeEvidenceCollection(t, updateWithoutEvidence, err, false) + + updateWithEvidence, err := controller.runtimePostHandshakeUpdate(qemuPreparedStateForEvidenceTest(spec, admission, receipt, secureSession, &launcherbackend.PostHandshakeRuntimeAttestationInput{ + RunID: receipt.RunID, + IsolateID: receipt.IsolateID, + SessionID: receipt.SessionID, + SessionNonce: receipt.SessionNonce, + LaunchContextDigest: secureSession.LaunchContext.LaunchContextDigest, + HandshakeTranscriptHash: secureSession.SessionReady.HandshakeTranscriptHash, + IsolateSessionKeyIDValue: secureSession.SessionReady.IsolateKeyIDValue, + RuntimeImageDescriptorDigest: receipt.RuntimeImageDescriptorDigest, + RuntimeImageBootProfile: receipt.RuntimeImageBootProfile, + RuntimeEvidenceCollected: true, + AttestationSourceKind: launcherbackend.AttestationSourceKindTrustedRuntime, + MeasurementProfile: admission.AttestationMeasurementProfile, + FreshnessMaterial: []string{"session_nonce"}, + FreshnessBindingClaims: []string{"session_nonce", "handshake_transcript_hash", "launch_context_digest"}, + EvidenceClaimsDigest: admission.AttestationExpectedMeasurementDigests[0], + })) + assertQEMURuntimeEvidenceCollection(t, updateWithEvidence, err, true) + + evidence, _, err := launcherbackend.SplitRuntimeFactsEvidenceAndLifecycle(*updateWithEvidence.Facts) + if err != nil { + t.Fatalf("SplitRuntimeFactsEvidenceAndLifecycle returned error: %v", err) + } + if evidence.Launch.ProvisioningPosture == launcherbackend.ProvisioningPostureAttested { + t.Fatal("attested posture must not be synthesized without valid verification") + } +} + +func qemuPreparedStateForEvidenceTest(spec launcherbackend.BackendLaunchSpec, admission launcherbackend.RuntimeAdmissionRecord, receipt launcherbackend.BackendLaunchReceipt, secureSession *launcherbackend.RuntimeSecureSessionMaterial, attestation *launcherbackend.PostHandshakeRuntimeAttestationInput) preparedLaunchState { + return preparedLaunchState{ + spec: spec, + receipt: receipt, + admission: admission, + hardening: buildHardeningPosture(), + material: &launcherbackend.RuntimePostHandshakeMaterial{SecureSession: secureSession, Attestation: attestation}, + } +} + +func assertQEMURuntimeEvidenceCollection(t *testing.T, update RuntimeUpdate, err error, wantCollected bool) { + t.Helper() + if err != nil { + t.Fatalf("runtimePostHandshakeUpdate returned error: %v", err) + } + if update.Facts == nil || update.Facts.PostHandshakeAttestationInput == nil { + t.Fatal("runtimePostHandshakeUpdate missing post-handshake input") + } + if update.Facts.PostHandshakeAttestationInput.RuntimeEvidenceCollected != wantCollected { + t.Fatalf("runtime evidence collected = %v, want %v", update.Facts.PostHandshakeAttestationInput.RuntimeEvidenceCollected, wantCollected) + } +} + +func TestQEMURuntimePostHandshakeUpdateFailsClosedOnInvalidRuntimeMaterial(t *testing.T) { + spec, admission, receipt := runtimeAttestationReceiptFixtureForValidation(t) + secureSession := mustRuntimeSecureSessionMaterialForTests(t, spec, receipt) + controller := &qemuController{cfg: QEMUControllerConfig{}, instances: map[string]*qemuInstance{}} + _, err := controller.runtimePostHandshakeUpdate(preparedLaunchState{ + spec: spec, + receipt: receipt, + admission: admission, + hardening: buildHardeningPosture(), + material: &launcherbackend.RuntimePostHandshakeMaterial{ + SecureSession: secureSession, + Attestation: &launcherbackend.PostHandshakeRuntimeAttestationInput{ + RunID: receipt.RunID, + IsolateID: receipt.IsolateID, + SessionID: receipt.SessionID, + SessionNonce: receipt.SessionNonce, + LaunchContextDigest: secureSession.LaunchContext.LaunchContextDigest, + HandshakeTranscriptHash: secureSession.SessionReady.HandshakeTranscriptHash, + IsolateSessionKeyIDValue: secureSession.SessionReady.IsolateKeyIDValue, + RuntimeImageDescriptorDigest: receipt.RuntimeImageDescriptorDigest, + RuntimeImageBootProfile: receipt.RuntimeImageBootProfile, + RuntimeEvidenceCollected: true, + AttestationSourceKind: launcherbackend.AttestationSourceKindTrustedRuntime, + MeasurementProfile: admission.AttestationMeasurementProfile, + EvidenceClaimsDigest: "sha256:" + strings.Repeat("f", 64), + }, + }, + }) + if err == nil { + t.Fatal("runtimePostHandshakeUpdate expected invalid runtime material error") + } + if !strings.Contains(err.Error(), "runtime-reported evidence_claims_digest must bind to admitted runtime identity") { + t.Fatalf("runtimePostHandshakeUpdate error = %q, want admitted runtime identity binding failure", err.Error()) + } +} + +func TestBuildTerminalReportFailsClosedWhenErrorPresentAfterHello(t *testing.T) { + report := buildTerminalReport(validSpecForTests(), launcherbackend.BackendLaunchReceipt{IsolateID: "iso-1", SessionID: "session-1"}, true, launcherbackend.BackendErrorCodeHandshakeFailed) + if report.TerminationKind != launcherbackend.BackendTerminationKindFailed { + t.Fatalf("termination kind = %q, want failed", report.TerminationKind) + } + if report.FailureReasonCode != launcherbackend.BackendErrorCodeHandshakeFailed { + t.Fatalf("failure_reason_code = %q, want %q", report.FailureReasonCode, launcherbackend.BackendErrorCodeHandshakeFailed) } } diff --git a/internal/launcherdaemon/qemu_controller_unsupported.go b/internal/launcherdaemon/qemu_controller_unsupported.go index 5b4b0769..847d60f3 100644 --- a/internal/launcherdaemon/qemu_controller_unsupported.go +++ b/internal/launcherdaemon/qemu_controller_unsupported.go @@ -11,10 +11,11 @@ import ( ) type QEMUControllerConfig struct { - QEMUBinary string - KernelPath string - WorkRoot string - Now func() time.Time + QEMUBinary string + KernelPath string + WorkRoot string + Now func() time.Time + RuntimePostHandshakeMaterialProvider func(launcherbackend.BackendLaunchSpec, launcherbackend.BackendLaunchReceipt) (*launcherbackend.RuntimePostHandshakeMaterial, error) } type unsupportedController struct{} diff --git a/internal/launcherdaemon/qemu_initramfs_linux.go b/internal/launcherdaemon/qemu_initramfs_linux.go index 1f7f84b5..4ebc516c 100644 --- a/internal/launcherdaemon/qemu_initramfs_linux.go +++ b/internal/launcherdaemon/qemu_initramfs_linux.go @@ -49,12 +49,23 @@ func helloInitProgram() string { return `package main import ( "fmt" + "os" + "strings" "syscall" ) func main() { + cmdline, err := os.ReadFile("/proc/cmdline") + if err == nil { + for _, arg := range strings.Fields(string(cmdline)) { + if line, ok := strings.CutPrefix(arg, "RUNE_POST_HANDSHAKE_MATERIAL_LINE="); ok && line != "" { + fmt.Println(line) + break + } + } + } fmt.Println("` + helloWorldToken + `") _ = syscall.Reboot(syscall.LINUX_REBOOT_CMD_POWER_OFF) -}` + }` } func buildHelloInitBinary(ctx context.Context, goBin, binPath, src string) error { diff --git a/internal/launcherdaemon/qemu_initramfs_linux_test.go b/internal/launcherdaemon/qemu_initramfs_linux_test.go index cd1f29ec..5c1c7a34 100644 --- a/internal/launcherdaemon/qemu_initramfs_linux_test.go +++ b/internal/launcherdaemon/qemu_initramfs_linux_test.go @@ -89,6 +89,30 @@ func TestBuildHelloInitBinaryUsesStagingDir(t *testing.T) { } } +func TestHelloInitProgramEmitsRuntimePostHandshakeMaterialBeforeHello(t *testing.T) { + program := helloInitProgram() + runtimeLineIndex := strings.Index(program, "/proc/cmdline") + helloIndex := strings.Index(program, helloWorldToken) + if runtimeLineIndex < 0 { + t.Fatal("hello init program must read runtime post-handshake material line from guest cmdline") + } + if helloIndex < 0 { + t.Fatal("hello init program must emit hello token") + } + if runtimeLineIndex > helloIndex { + t.Fatal("hello init program must emit runtime post-handshake material before hello token") + } +} + +func TestQEMUGuestRuntimeMaterialKernelArg(t *testing.T) { + if got := qemuGuestRuntimeMaterialKernelArg(""); got != "" { + t.Fatalf("guest material arg for empty input = %q, want empty", got) + } + if got := qemuGuestRuntimeMaterialKernelArg("RUNE_POST_HANDSHAKE_MATERIAL=abc"); got != "RUNE_POST_HANDSHAKE_MATERIAL_LINE=RUNE_POST_HANDSHAKE_MATERIAL=abc" { + t.Fatalf("guest material arg = %q", got) + } +} + func setHelloWorldGoBinaryCandidatesForTests(t *testing.T, candidates []string) { t.Helper() previous := helloWorldGoBinaryCandidates diff --git a/internal/launcherdaemon/qemu_launch_state_linux.go b/internal/launcherdaemon/qemu_launch_state_linux.go new file mode 100644 index 00000000..9515d624 --- /dev/null +++ b/internal/launcherdaemon/qemu_launch_state_linux.go @@ -0,0 +1,147 @@ +//go:build linux + +package launcherdaemon + +import ( + "context" + "io" + "os" + "os/exec" + "strings" + "syscall" + + "github.com/runecode-ai/runecode/internal/launcherbackend" +) + +type launchStateStdout = io.Reader +type launchStateCmd = *exec.Cmd + +func (c *qemuController) prepareLaunchState(ctx context.Context, spec launcherbackend.BackendLaunchSpec) (preparedLaunchState, error) { + if err := ctx.Err(); err != nil { + return preparedLaunchState{}, err + } + if err := c.validateLaunchPrereqs(spec); err != nil { + return preparedLaunchState{}, err + } + qemuPath := strings.TrimSpace(c.cfg.QEMUBinary) + admittedImage, launchDir, kernelPath, initrdPath, err := c.prepareLaunchAssets(ctx, qemuPath, spec) + if err != nil { + return preparedLaunchState{}, err + } + receipt, err := c.prepareLaunchReceipt(spec, admittedImage.admissionRecord, qemuPath, admittedImage.cacheEvidence) + if err != nil { + return preparedLaunchState{}, err + } + guestMaterialArg, err := c.prepareQEMUGuestMaterialArg(spec, receipt) + if err != nil { + return preparedLaunchState{}, err + } + cmd, stdout, cancel, err := c.startQEMUProcess(ctx, qemuPath, kernelPath, initrdPath, spec.ResourceLimits, guestMaterialArg) + if err != nil { + return preparedLaunchState{}, err + } + return preparedLaunchState{ + stdout: stdout, + launchDir: launchDir, + receipt: receipt, + hardening: buildHardeningPosture(), + admission: admittedImage.admissionRecord, + material: nil, + spec: spec, + cmd: cmd, + cancel: cancel, + }, nil +} + +func (c *qemuController) prepareLaunchReceipt(spec launcherbackend.BackendLaunchSpec, admission launcherbackend.RuntimeAdmissionRecord, qemuPath string, cacheEvidence *launcherbackend.BackendCacheEvidence) (launcherbackend.BackendLaunchReceipt, error) { + isoID, sessionID, nonce, err := makeRuntimeIdentity(spec.RunID) + if err != nil { + return launcherbackend.BackendLaunchReceipt{}, backendError(launcherbackend.BackendErrorCodeHypervisorLaunchFailed, "failed to generate runtime identity") + } + qemuVersion, qemuBuild := detectQEMUProvenance(qemuPath) + receipt, err := buildLaunchReceipt(spec, admission, isoID, sessionID, nonce, qemuVersion, qemuBuild, cacheEvidence) + if err != nil { + return launcherbackend.BackendLaunchReceipt{}, backendError(launcherbackend.BackendErrorCodeHandshakeFailed, err.Error()) + } + return receipt, nil +} + +func (c *qemuController) prepareQEMUGuestMaterialArg(spec launcherbackend.BackendLaunchSpec, receipt launcherbackend.BackendLaunchReceipt) (string, error) { + seed, err := c.runtimePostHandshakeSeed(spec, receipt) + if err != nil { + return "", err + } + runtimeMaterialLine, err := encodeQEMURuntimePostHandshakeMaterialLine(seed) + if err != nil { + return "", backendError(launcherbackend.BackendErrorCodeHandshakeFailed, err.Error()) + } + return qemuGuestRuntimeMaterialKernelArg(runtimeMaterialLine), nil +} + +func (c *qemuController) runtimePostHandshakeSeed(spec launcherbackend.BackendLaunchSpec, receipt launcherbackend.BackendLaunchReceipt) (*launcherbackend.RuntimePostHandshakeMaterial, error) { + if c.cfg.RuntimePostHandshakeMaterialProvider == nil { + return nil, nil + } + seed, err := c.cfg.RuntimePostHandshakeMaterialProvider(spec, receipt) + if err != nil { + return nil, backendError(launcherbackend.BackendErrorCodeHandshakeFailed, err.Error()) + } + return seed, nil +} + +func (c *qemuController) prepareLaunchAssets(ctx context.Context, qemuPath string, spec launcherbackend.BackendLaunchSpec) (admittedRuntimeImage, string, string, string, error) { + admittedImage, err := admitRuntimeImage(c.cfg.WorkRoot, spec.Image) + if err != nil { + return admittedRuntimeImage{}, "", "", "", err + } + launchDir, err := c.prepareLaunchDir(spec) + if err != nil { + return admittedRuntimeImage{}, "", "", "", backendError(launcherbackend.BackendErrorCodeAttachmentPlanInvalid, "failed to materialize attachments") + } + keepLaunchDir := false + defer func() { + if !keepLaunchDir { + _ = os.RemoveAll(launchDir) + } + }() + if err := ctx.Err(); err != nil { + return admittedRuntimeImage{}, "", "", "", err + } + if err := verifyRuntimeToolchainArtifact(qemuPath, admittedImage.toolchain); err != nil { + return admittedRuntimeImage{}, "", "", "", backendError(launcherbackend.BackendErrorCodeImageDescriptorSignatureMismatch, err.Error()) + } + keepLaunchDir = true + return admittedImage, launchDir, admittedImage.componentPaths["kernel"], admittedImage.componentPaths["initrd"], nil +} + +func (c *qemuController) startQEMUProcess(ctx context.Context, qemuPath, kernelPath, initrdPath string, limits launcherbackend.BackendResourceLimits, guestMaterialArg string) (*exec.Cmd, io.Reader, context.CancelFunc, error) { + if err := ctx.Err(); err != nil { + return nil, nil, nil, err + } + argv := buildQEMUArgv(qemuPath, kernelPath, initrdPath, limits, guestMaterialArg) + cmd := exec.Command(argv[0], argv[1:]...) + launchCancel := func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + } + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGKILL} + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, nil, nil, backendError(launcherbackend.BackendErrorCodeHypervisorLaunchFailed, "failed to prepare qemu output stream") + } + cmd.Stderr = cmd.Stdout + if err := cmd.Start(); err != nil { + launchCancel() + if strings.Contains(strings.ToLower(err.Error()), "kvm") { + return nil, nil, nil, backendError(launcherbackend.BackendErrorCodeAccelerationUnavailable, "kvm initialization failed") + } + return nil, nil, nil, backendError(launcherbackend.BackendErrorCodeHypervisorLaunchFailed, "qemu launch failed") + } + if err := ctx.Err(); err != nil { + launchCancel() + _ = cmd.Process.Kill() + return nil, nil, nil, err + } + return cmd, stdout, launchCancel, nil +} diff --git a/internal/launcherdaemon/qemu_launch_support_linux.go b/internal/launcherdaemon/qemu_launch_support_linux.go index e5824c58..73fbe804 100644 --- a/internal/launcherdaemon/qemu_launch_support_linux.go +++ b/internal/launcherdaemon/qemu_launch_support_linux.go @@ -19,7 +19,7 @@ import ( "github.com/runecode-ai/runecode/internal/launcherbackend" ) -func buildLaunchReceipt(spec launcherbackend.BackendLaunchSpec, admission launcherbackend.RuntimeAdmissionRecord, isoID, sessionID, nonce, qemuVersion, qemuBuild string, cacheEvidence *launcherbackend.BackendCacheEvidence, now time.Time) (launcherbackend.BackendLaunchReceipt, error) { +func buildLaunchReceipt(spec launcherbackend.BackendLaunchSpec, admission launcherbackend.RuntimeAdmissionRecord, isoID, sessionID, nonce, qemuVersion, qemuBuild string, cacheEvidence *launcherbackend.BackendCacheEvidence) (launcherbackend.BackendLaunchReceipt, error) { sessionBinding, err := deriveRuntimeSessionBinding(spec, admission.DescriptorDigest, isoID, sessionID, nonce) if err != nil { return launcherbackend.BackendLaunchReceipt{}, err @@ -36,9 +36,6 @@ func buildLaunchReceipt(spec launcherbackend.BackendLaunchSpec, admission launch populateRuntimeSessionBinding(&receipt, sessionBinding) applyRuntimeAssetIdentity(&receipt, admission, qemuVersion, qemuBuild) applyLaunchExecutionDetails(&receipt, spec, cacheEvidence, qemuVersion, qemuBuild) - if err := applyTrustedRuntimeAttestation(&receipt, admission, now); err != nil { - return launcherbackend.BackendLaunchReceipt{}, err - } return receipt, nil } @@ -140,7 +137,7 @@ func writeAttachmentManifest(root, role string, binding launcherbackend.Attachme return os.WriteFile(filepath.Join(roleDir, "manifest.json"), raw, 0o600) } -func buildQEMUArgv(binary, kernel, initrd string, limits launcherbackend.BackendResourceLimits) []string { +func buildQEMUArgv(binary, kernel, initrd string, limits launcherbackend.BackendResourceLimits, guestMaterialArg string) []string { memory := limits.MemoryMiB if memory <= 0 { memory = 256 @@ -149,6 +146,10 @@ func buildQEMUArgv(binary, kernel, initrd string, limits launcherbackend.Backend if vcpus <= 0 { vcpus = 1 } + appendValue := "console=ttyS0 panic=-1" + if strings.TrimSpace(guestMaterialArg) != "" { + appendValue += " " + guestMaterialArg + } return []string{ binary, "-nodefaults", @@ -163,7 +164,7 @@ func buildQEMUArgv(binary, kernel, initrd string, limits launcherbackend.Backend "-nic", "none", "-kernel", kernel, "-initrd", initrd, - "-append", "console=ttyS0 panic=-1", + "-append", appendValue, } } @@ -210,17 +211,6 @@ func makeRuntimeIdentity(runID string) (string, string, string, error) { return iso, session, nonce, nil } -func cloneMap(in map[string]string) map[string]string { - if len(in) == 0 { - return nil - } - out := make(map[string]string, len(in)) - for k, v := range in { - out[k] = v - } - return out -} - func safeToken(in string) string { v := strings.TrimSpace(strings.ToLower(in)) if v == "" { diff --git a/internal/launcherdaemon/qemu_runtime_attestation_material_linux.go b/internal/launcherdaemon/qemu_runtime_attestation_material_linux.go new file mode 100644 index 00000000..8e723a3d --- /dev/null +++ b/internal/launcherdaemon/qemu_runtime_attestation_material_linux.go @@ -0,0 +1,167 @@ +//go:build linux + +package launcherdaemon + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/launcherbackend" +) + +const ( + qemuRuntimeMaterialToken = "RUNE_POST_HANDSHAKE_MATERIAL=" +) + +type qemuRuntimeMaterialEnvelope struct { + SecureSession *launcherbackend.RuntimeSecureSessionMaterial `json:"secure_session,omitempty"` + Attestation *launcherbackend.PostHandshakeRuntimeAttestationInput `json:"attestation,omitempty"` +} + +func defaultQEMURuntimePostHandshakeMaterialProvider(spec launcherbackend.BackendLaunchSpec, receipt launcherbackend.BackendLaunchReceipt) (*launcherbackend.RuntimePostHandshakeMaterial, error) { + return defaultRuntimePostHandshakeMaterialProvider(spec, receipt) +} + +func qemuGuestRuntimeMaterialKernelArg(runtimeMaterialLine string) string { + trimmed := strings.TrimSpace(runtimeMaterialLine) + if trimmed == "" { + return "" + } + return "RUNE_POST_HANDSHAKE_MATERIAL_LINE=" + trimmed +} + +func encodeQEMURuntimePostHandshakeMaterialPayload(material *launcherbackend.RuntimePostHandshakeMaterial) (string, error) { + if material == nil { + return "", nil + } + if material.SecureSession == nil && material.Attestation == nil { + return "", nil + } + envelope := qemuRuntimeMaterialEnvelope{SecureSession: material.SecureSession, Attestation: material.Attestation} + raw, err := json.Marshal(envelope) + if err != nil { + return "", fmt.Errorf("qemu runtime post-handshake material encode failed: %w", err) + } + return base64.StdEncoding.EncodeToString(raw), nil +} + +func encodeQEMURuntimePostHandshakeMaterialLine(material *launcherbackend.RuntimePostHandshakeMaterial) (string, error) { + payload, err := encodeQEMURuntimePostHandshakeMaterialPayload(material) + if err != nil { + return "", err + } + if payload == "" { + return "", nil + } + return qemuRuntimeMaterialToken + payload, nil +} + +func parseQEMURuntimeMaterialLine(line string) (*launcherbackend.RuntimePostHandshakeMaterial, error) { + payload := strings.TrimSpace(line) + if !strings.HasPrefix(payload, qemuRuntimeMaterialToken) { + return nil, nil + } + payload = strings.TrimSpace(strings.TrimPrefix(payload, qemuRuntimeMaterialToken)) + if payload == "" { + return nil, fmt.Errorf("qemu runtime post-handshake material payload is required") + } + decoded, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + return nil, fmt.Errorf("qemu runtime post-handshake material decode failed: %w", err) + } + decoded = bytes.TrimSpace(decoded) + if len(decoded) == 0 { + return nil, fmt.Errorf("qemu runtime post-handshake material payload is required") + } + var envelope qemuRuntimeMaterialEnvelope + if err := json.Unmarshal(decoded, &envelope); err != nil { + return nil, fmt.Errorf("qemu runtime post-handshake material parse failed: %w", err) + } + material := &launcherbackend.RuntimePostHandshakeMaterial{SecureSession: envelope.SecureSession, Attestation: envelope.Attestation} + if material.SecureSession == nil && material.Attestation == nil { + return nil, fmt.Errorf("qemu runtime post-handshake material is empty") + } + return material, nil +} + +func mergeQEMURuntimePostHandshakeMaterial(seed, runtime *launcherbackend.RuntimePostHandshakeMaterial) *launcherbackend.RuntimePostHandshakeMaterial { + if seed == nil { + return runtime + } + if runtime == nil { + return seed + } + merged := &launcherbackend.RuntimePostHandshakeMaterial{} + if runtime.SecureSession != nil { + merged.SecureSession = runtime.SecureSession + } else { + merged.SecureSession = seed.SecureSession + } + merged.Attestation = mergeQEMURuntimeAttestation(seed.Attestation, runtime.Attestation) + return merged +} + +func mergeQEMURuntimeAttestation(seed, runtime *launcherbackend.PostHandshakeRuntimeAttestationInput) *launcherbackend.PostHandshakeRuntimeAttestationInput { + if seed == nil { + return runtime + } + if runtime == nil { + return seed + } + merged := *runtime + mergeQEMUAuthoritativeAttestationFields(&merged, seed) + mergeQEMURuntimeAttestationFields(&merged, seed) + if len(merged.BootComponentDigestByName) == 0 { + merged.BootComponentDigestByName = cloneMap(seed.BootComponentDigestByName) + } + if len(merged.BootComponentDigests) == 0 { + merged.BootComponentDigests = append([]string{}, seed.BootComponentDigests...) + } + return &merged +} + +func mergeQEMUAuthoritativeAttestationFields(merged *launcherbackend.PostHandshakeRuntimeAttestationInput, seed *launcherbackend.PostHandshakeRuntimeAttestationInput) { + if merged == nil || seed == nil { + return + } + merged.RunID = seed.RunID + merged.IsolateID = seed.IsolateID + merged.SessionID = seed.SessionID + merged.SessionNonce = seed.SessionNonce + merged.LaunchContextDigest = seed.LaunchContextDigest + merged.HandshakeTranscriptHash = seed.HandshakeTranscriptHash + merged.IsolateSessionKeyIDValue = seed.IsolateSessionKeyIDValue + merged.RuntimeImageDescriptorDigest = seed.RuntimeImageDescriptorDigest + merged.RuntimeImageBootProfile = seed.RuntimeImageBootProfile + merged.RuntimeImageVerifierRef = seed.RuntimeImageVerifierRef + merged.AuthorityStateDigest = seed.AuthorityStateDigest + merged.BootComponentDigestByName = cloneMap(seed.BootComponentDigestByName) + merged.BootComponentDigests = append([]string{}, seed.BootComponentDigests...) +} + +func mergeQEMURuntimeAttestationFields(merged *launcherbackend.PostHandshakeRuntimeAttestationInput, seed *launcherbackend.PostHandshakeRuntimeAttestationInput) { + if merged == nil || seed == nil { + return + } + if merged.AttestationSourceKind == "" || merged.AttestationSourceKind == launcherbackend.AttestationSourceKindUnknown { + merged.AttestationSourceKind = seed.AttestationSourceKind + } + if merged.MeasurementProfile == "" || merged.MeasurementProfile == launcherbackend.MeasurementProfileUnknown { + merged.MeasurementProfile = seed.MeasurementProfile + } + if !merged.RuntimeEvidenceCollected { + merged.RuntimeEvidenceCollected = seed.RuntimeEvidenceCollected + } + if merged.EvidenceClaimsDigest == "" { + merged.EvidenceClaimsDigest = seed.EvidenceClaimsDigest + } + if len(merged.FreshnessMaterial) == 0 { + merged.FreshnessMaterial = append([]string{}, seed.FreshnessMaterial...) + } + if len(merged.FreshnessBindingClaims) == 0 { + merged.FreshnessBindingClaims = append([]string{}, seed.FreshnessBindingClaims...) + } +} diff --git a/internal/launcherdaemon/qemu_runtime_attestation_material_linux_test.go b/internal/launcherdaemon/qemu_runtime_attestation_material_linux_test.go new file mode 100644 index 00000000..ba6cdbac --- /dev/null +++ b/internal/launcherdaemon/qemu_runtime_attestation_material_linux_test.go @@ -0,0 +1,117 @@ +//go:build linux + +package launcherdaemon + +import ( + "testing" + + "github.com/runecode-ai/runecode/internal/launcherbackend" +) + +func TestMergeQEMURuntimePostHandshakeMaterialPreservesRuntimeProducedAttestationFields(t *testing.T) { + merged := mergeQEMURuntimePostHandshakeMaterial(seedRuntimeAttestationMaterial(), runtimeProducedAttestationMaterial()) + assertRuntimeAttestationFields(t, merged) +} + +func TestMergeQEMURuntimePostHandshakeMaterialRetainsSeedBindingWhenRuntimeOmitsIt(t *testing.T) { + seed := seedRuntimeAttestationMaterial() + merged := mergeQEMURuntimePostHandshakeMaterial(seed, runtimeProducedAttestationMaterial()) + assertSeedBindingRetained(t, merged, seed) +} + +func TestMergeQEMURuntimePostHandshakeMaterialAccumulatesAcrossIncrementalRuntimeLines(t *testing.T) { + secureSession := &launcherbackend.RuntimeSecureSessionMaterial{ + LaunchContext: launcherbackend.LaunchContext{RunID: "run-1", SessionID: "session-1", SessionNonce: "nonce-1", LaunchContextDigest: "sha256:launch"}, + SessionReady: launcherbackend.SessionReady{RunID: "run-1", IsolateID: "iso-1", SessionID: "session-1", SessionNonce: "nonce-1", HandshakeTranscriptHash: "sha256:transcript", IsolateKeyIDValue: "key-id"}, + } + first := &launcherbackend.RuntimePostHandshakeMaterial{SecureSession: secureSession} + second := &launcherbackend.RuntimePostHandshakeMaterial{Attestation: &launcherbackend.PostHandshakeRuntimeAttestationInput{RuntimeEvidenceCollected: true, AttestationSourceKind: launcherbackend.AttestationSourceKindTrustedRuntime, MeasurementProfile: launcherbackend.MeasurementProfileMicroVMBootV1, EvidenceClaimsDigest: "sha256:evidence"}} + + merged := mergeQEMURuntimePostHandshakeMaterial(first, second) + if merged == nil || merged.SecureSession == nil { + t.Fatal("merged secure session missing after incremental runtime lines") + } + if merged.Attestation == nil { + t.Fatal("merged attestation missing after incremental runtime lines") + } + if got, want := merged.Attestation.EvidenceClaimsDigest, "sha256:evidence"; got != want { + t.Fatalf("evidence claims digest = %q, want %q", got, want) + } +} + +func seedRuntimeAttestationMaterial() *launcherbackend.RuntimePostHandshakeMaterial { + return &launcherbackend.RuntimePostHandshakeMaterial{ + Attestation: &launcherbackend.PostHandshakeRuntimeAttestationInput{ + RunID: "run-1", + IsolateID: "isolate-1", + SessionID: "session-1", + SessionNonce: "nonce-1", + LaunchContextDigest: "sha256:launch", + HandshakeTranscriptHash: "sha256:transcript", + IsolateSessionKeyIDValue: "key-id", + RuntimeImageDescriptorDigest: "sha256:image", + RuntimeImageBootProfile: launcherbackend.BootProfileMicroVMLinuxKernelInitrdV1, + RuntimeEvidenceCollected: false, + AttestationSourceKind: launcherbackend.AttestationSourceKindUnknown, + MeasurementProfile: launcherbackend.MeasurementProfileUnknown, + EvidenceClaimsDigest: "sha256:seed-evidence", + }, + } +} + +func runtimeProducedAttestationMaterial() *launcherbackend.RuntimePostHandshakeMaterial { + return &launcherbackend.RuntimePostHandshakeMaterial{ + Attestation: &launcherbackend.PostHandshakeRuntimeAttestationInput{ + RuntimeEvidenceCollected: true, + AttestationSourceKind: launcherbackend.AttestationSourceKindTrustedRuntime, + MeasurementProfile: launcherbackend.MeasurementProfileMicroVMBootV1, + FreshnessMaterial: []string{"session_nonce"}, + FreshnessBindingClaims: []string{"session_nonce", "handshake_transcript_hash", "launch_context_digest"}, + EvidenceClaimsDigest: "sha256:runtime-evidence", + }, + } +} + +func assertRuntimeAttestationFields(t *testing.T, merged *launcherbackend.RuntimePostHandshakeMaterial) { + t.Helper() + if merged == nil || merged.Attestation == nil { + t.Fatal("merged runtime post-handshake material attestation is required") + } + if got, want := merged.Attestation.EvidenceClaimsDigest, "sha256:runtime-evidence"; got != want { + t.Fatalf("evidence claims digest = %q, want %q", got, want) + } + if got, want := merged.Attestation.AttestationSourceKind, launcherbackend.AttestationSourceKindTrustedRuntime; got != want { + t.Fatalf("attestation source kind = %q, want %q", got, want) + } + if got, want := merged.Attestation.MeasurementProfile, launcherbackend.MeasurementProfileMicroVMBootV1; got != want { + t.Fatalf("measurement profile = %q, want %q", got, want) + } + if !merged.Attestation.RuntimeEvidenceCollected { + t.Fatal("runtime evidence collected should remain true") + } +} + +func assertSeedBindingRetained(t *testing.T, merged *launcherbackend.RuntimePostHandshakeMaterial, seed *launcherbackend.RuntimePostHandshakeMaterial) { + t.Helper() + if merged == nil || merged.Attestation == nil || seed == nil || seed.Attestation == nil { + t.Fatal("merged runtime post-handshake material attestation is required") + } + if got, want := merged.Attestation.RunID, seed.Attestation.RunID; got != want { + t.Fatalf("run_id = %q, want %q", got, want) + } + if got, want := merged.Attestation.IsolateID, seed.Attestation.IsolateID; got != want { + t.Fatalf("isolate_id = %q, want %q", got, want) + } + if got, want := merged.Attestation.SessionID, seed.Attestation.SessionID; got != want { + t.Fatalf("session_id = %q, want %q", got, want) + } + if got, want := merged.Attestation.LaunchContextDigest, seed.Attestation.LaunchContextDigest; got != want { + t.Fatalf("launch_context_digest = %q, want %q", got, want) + } + if got, want := merged.Attestation.HandshakeTranscriptHash, seed.Attestation.HandshakeTranscriptHash; got != want { + t.Fatalf("handshake_transcript_hash = %q, want %q", got, want) + } + if got, want := merged.Attestation.EvidenceClaimsDigest, "sha256:runtime-evidence"; got != want { + t.Fatalf("evidence claims digest = %q, want %q", got, want) + } +} diff --git a/internal/launcherdaemon/qemu_runtime_cleanup_linux.go b/internal/launcherdaemon/qemu_runtime_cleanup_linux.go new file mode 100644 index 00000000..bbe65626 --- /dev/null +++ b/internal/launcherdaemon/qemu_runtime_cleanup_linux.go @@ -0,0 +1,31 @@ +//go:build linux + +package launcherdaemon + +import ( + "fmt" + + "github.com/runecode-ai/runecode/internal/launcherbackend" +) + +func qemuKillAndReap(inst *qemuInstance) { + if inst == nil || inst.cmd == nil { + return + } + if inst.cmd.Process != nil { + _ = inst.cmd.Process.Kill() + } + _ = inst.cmd.Wait() +} + +func qemuWaitCancelled(inst *qemuInstance, runtimeMaterial *launcherbackend.RuntimePostHandshakeMaterial, err error) (*launcherbackend.RuntimePostHandshakeMaterial, bool, error) { + _ = inst.cmd.Process.Kill() + inst.errText = launcherbackend.BackendErrorCodeHandshakeFailed + return runtimeMaterial, inst.helloSeen, err +} + +func qemuWaitTimedOut(inst *qemuInstance, runtimeMaterial *launcherbackend.RuntimePostHandshakeMaterial) (*launcherbackend.RuntimePostHandshakeMaterial, bool, error) { + _ = inst.cmd.Process.Kill() + inst.errText = launcherbackend.BackendErrorCodeWatchdogTimeout + return runtimeMaterial, inst.helloSeen, fmt.Errorf("watchdog timeout waiting for runtime material") +} diff --git a/internal/launcherdaemon/qemu_runtime_linux.go b/internal/launcherdaemon/qemu_runtime_linux.go index f10bd292..b23d8914 100644 --- a/internal/launcherdaemon/qemu_runtime_linux.go +++ b/internal/launcherdaemon/qemu_runtime_linux.go @@ -5,7 +5,6 @@ package launcherdaemon import ( "bufio" "context" - "io" "os" "strings" "time" @@ -13,19 +12,47 @@ import ( "github.com/runecode-ai/runecode/internal/launcherbackend" ) -func (c *qemuController) monitorInstance(parent context.Context, inst *qemuInstance, spec launcherbackend.BackendLaunchSpec, out io.Reader, hardening launcherbackend.AppliedHardeningPosture, receipt launcherbackend.BackendLaunchReceipt) { +func (c *qemuController) monitorInstance(parent context.Context, inst *qemuInstance, launchState preparedLaunchState) { defer removeLaunchDir(inst.launchDir) - scanStop := make(chan struct{}) - defer close(scanStop) - lineCh, scanDone := scanQEMUOutput(out, scanStop) - helloSeen := c.waitForHelloOrExit(parent, inst, spec, lineCh, scanDone) + currentReceipt := launchState.receipt + var currentPostHandshake *launcherbackend.PostHandshakeRuntimeAttestationInput + runtimeMaterial, helloSeen, waitErr := waitForHelloAndRuntimeMaterial(parent, c, inst, launchState) + if waitErr != nil { + qemuKillAndReap(inst) + c.finishQEMUInstanceWithTerminal(inst, launchState, currentReceipt, currentPostHandshake, helloSeen) + return + } + launchState.material = mergeQEMURuntimePostHandshakeMaterial(launchState.material, runtimeMaterial) + postHandshakeFailed := false + if helloSeen { + if update, err := c.runtimePostHandshakeUpdate(launchState); err == nil { + if update.Facts != nil { + currentReceipt = update.Facts.LaunchReceipt + currentPostHandshake = update.Facts.PostHandshakeAttestationInput + } + inst.updates <- update + active := lifecycleUpdate(launcherbackend.BackendLifecycleStateActive, launcherbackend.BackendLifecycleStateBinding, 4, "") + inst.updates <- RuntimeUpdate{RunID: launchState.spec.RunID, Lifecycle: &active} + c.mu.Lock() + inst.state.LifecycleState = active + c.mu.Unlock() + } else { + postHandshakeFailed = true + inst.errText = launcherbackend.BackendErrorCodeHandshakeFailed + _ = inst.cmd.Process.Kill() + } + } _ = inst.cmd.Wait() - term := buildTerminalReport(spec, receipt, helloSeen, inst.errText) - facts := launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: receipt, HardeningPosture: hardening, TerminalReport: &term} - inst.updates <- RuntimeUpdate{RunID: spec.RunID, Facts: &facts} + c.finishQEMUInstanceWithTerminal(inst, launchState, currentReceipt, currentPostHandshake, helloSeen && !postHandshakeFailed) +} + +func (c *qemuController) finishQEMUInstanceWithTerminal(inst *qemuInstance, launchState preparedLaunchState, currentReceipt launcherbackend.BackendLaunchReceipt, currentPostHandshake *launcherbackend.PostHandshakeRuntimeAttestationInput, helloSeen bool) { + term := buildTerminalReport(launchState.spec, currentReceipt, helloSeen, inst.errText) + facts := launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: currentReceipt, PostHandshakeAttestationInput: currentPostHandshake, HardeningPosture: launchState.hardening, TerminalReport: &term} + inst.updates <- RuntimeUpdate{RunID: launchState.spec.RunID, Facts: &facts} terminating, terminated := terminalLifecycleUpdates(term) - inst.updates <- RuntimeUpdate{RunID: spec.RunID, Lifecycle: &terminating} - inst.updates <- RuntimeUpdate{RunID: spec.RunID, Lifecycle: &terminated} + inst.updates <- RuntimeUpdate{RunID: launchState.spec.RunID, Lifecycle: &terminating} + inst.updates <- RuntimeUpdate{RunID: launchState.spec.RunID, Lifecycle: &terminated} c.finishInstance(inst, terminated, term.FailureReasonCode) close(inst.updates) @@ -51,50 +78,83 @@ func (c *qemuController) finishInstance(inst *qemuInstance, terminated launcherb inst.state.LastError = failureReason } -func scanQEMUOutput(out io.Reader, stop <-chan struct{}) (<-chan string, <-chan struct{}) { +func waitForHelloAndRuntimeMaterial(parent context.Context, c *qemuController, inst *qemuInstance, launchState preparedLaunchState) (*launcherbackend.RuntimePostHandshakeMaterial, bool, error) { + timer := time.NewTimer(activeTimeout(launchState.spec.ResourceLimits)) + defer timer.Stop() lineCh := make(chan string, 16) - scanDone := make(chan struct{}) + errCh := make(chan error, 1) go func() { defer close(lineCh) - defer close(scanDone) - scanner := bufio.NewScanner(out) + scanner := bufio.NewScanner(launchState.stdout) + const maxFrame = 1024 * 1024 + buf := make([]byte, 64*1024) + scanner.Buffer(buf, maxFrame) for scanner.Scan() { - line := scanner.Text() - select { - case lineCh <- line: - case <-stop: - return - } + lineCh <- scanner.Text() } + errCh <- scanner.Err() }() - return lineCh, scanDone -} - -func (c *qemuController) waitForHelloOrExit(parent context.Context, inst *qemuInstance, spec launcherbackend.BackendLaunchSpec, lineCh <-chan string, scanDone <-chan struct{}) bool { - timer := time.NewTimer(activeTimeout(spec.ResourceLimits)) - defer timer.Stop() + var runtimeMaterial *launcherbackend.RuntimePostHandshakeMaterial for { select { case <-parent.Done(): - _ = inst.cmd.Process.Kill() - return inst.helloSeen + return qemuWaitCancelled(inst, runtimeMaterial, parent.Err()) case <-timer.C: - _ = inst.cmd.Process.Kill() - inst.errText = launcherbackend.BackendErrorCodeWatchdogTimeout - return inst.helloSeen + return qemuWaitTimedOut(inst, runtimeMaterial) case line, ok := <-lineCh: - if !ok { - return inst.helloSeen + nextMaterial, done, err := advanceQEMURuntimeWaitState(c, inst, runtimeMaterial, line, ok, errCh) + if done || err != nil { + return nextMaterial, inst.helloSeen, err } - if c.recordHelloLine(inst, line) { - continue - } - case <-scanDone: - return inst.helloSeen + runtimeMaterial = nextMaterial } } } +func advanceQEMURuntimeWaitState(c *qemuController, inst *qemuInstance, runtimeMaterial *launcherbackend.RuntimePostHandshakeMaterial, line string, ok bool, errCh <-chan error) (*launcherbackend.RuntimePostHandshakeMaterial, bool, error) { + updatedMaterial, done, err := handleQEMURuntimeOutputLine(c, inst, runtimeMaterial, line, ok, errCh) + if err != nil || done { + return updatedMaterial, done, err + } + nextMaterial := mergeQEMURuntimePostHandshakeMaterial(runtimeMaterial, updatedMaterial) + if runtimeMaterialReady(inst, nextMaterial) { + return nextMaterial, true, nil + } + return nextMaterial, false, nil +} + +func parseQEMURuntimeMaterialUpdate(line string) (*launcherbackend.RuntimePostHandshakeMaterial, bool, error) { + material, err := parseQEMURuntimeMaterialLine(line) + if err != nil { + return nil, false, err + } + return material, material != nil, nil +} + +func handleQEMURuntimeOutputLine(c *qemuController, inst *qemuInstance, runtimeMaterial *launcherbackend.RuntimePostHandshakeMaterial, line string, ok bool, errCh <-chan error) (*launcherbackend.RuntimePostHandshakeMaterial, bool, error) { + if !ok { + return runtimeMaterial, true, <-errCh + } + material, handled, err := parseQEMURuntimeMaterialUpdate(line) + if err != nil { + return nil, true, err + } + if handled { + return material, false, nil + } + if recordHelloLine(c, inst, line) { + return runtimeMaterial, false, nil + } + return runtimeMaterial, false, nil +} + +func runtimeMaterialReady(inst *qemuInstance, runtimeMaterial *launcherbackend.RuntimePostHandshakeMaterial) bool { + if inst == nil || runtimeMaterial == nil || runtimeMaterial.SecureSession == nil { + return false + } + return qemuHelloSeen(inst) +} + func activeTimeout(limits launcherbackend.BackendResourceLimits) time.Duration { timeout := time.Duration(limits.ActiveTimeoutSeconds) * time.Second if timeout <= 0 { @@ -103,17 +163,60 @@ func activeTimeout(limits launcherbackend.BackendResourceLimits) time.Duration { return timeout } -func (c *qemuController) recordHelloLine(inst *qemuInstance, line string) bool { - if !strings.Contains(line, helloWorldToken) { +func recordHelloLine(c *qemuController, inst *qemuInstance, line string) bool { + if strings.TrimSpace(line) != helloWorldToken { return false } c.mu.Lock() + defer c.mu.Unlock() + if inst.helloSeen { + return true + } inst.helloSeen = true inst.state.HelloWorldSeen = true - c.mu.Unlock() return true } +func qemuHelloSeen(inst *qemuInstance) bool { + if inst == nil { + return false + } + return inst.helloSeen +} + +func (c *qemuController) runtimePostHandshakeUpdate(launchState preparedLaunchState) (RuntimeUpdate, error) { + runtimeMaterial, err := c.runtimePostHandshakeMaterialForQEMU(launchState) + if err != nil { + return RuntimeUpdate{}, err + } + if runtimeMaterial == nil || runtimeMaterial.SecureSession == nil { + return RuntimeUpdate{}, backendError(launcherbackend.BackendErrorCodeHandshakeFailed, "runtime secure-session material not provided") + } + summary, launchContextDigest, err := validateSecureSessionAndBuildSummary(launchState.receipt, runtimeMaterial.SecureSession) + if err != nil { + return RuntimeUpdate{}, err + } + receipt := launchState.receipt + if err := recordValidatedSecureSession(&receipt, summary, launchContextDigest); err != nil { + return RuntimeUpdate{}, err + } + postHandshake, err := buildPostHandshakeAttestationProgressFromMaterial(receipt, launchState.admission, runtimeMaterial) + if err != nil { + return RuntimeUpdate{}, err + } + if err := recordPostHandshakeAttestationProgress(&receipt, postHandshake); err != nil { + return RuntimeUpdate{}, err + } + return RuntimeUpdate{RunID: launchState.spec.RunID, Facts: &launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: receipt, PostHandshakeAttestationInput: postHandshake, HardeningPosture: launchState.hardening}}, nil +} + +func (c *qemuController) runtimePostHandshakeMaterialForQEMU(launchState preparedLaunchState) (*launcherbackend.RuntimePostHandshakeMaterial, error) { + if launchState.material == nil { + return nil, nil + } + return launchState.material, nil +} + func buildTerminalReport(spec launcherbackend.BackendLaunchSpec, receipt launcherbackend.BackendLaunchReceipt, helloSeen bool, errText string) launcherbackend.BackendTerminalReport { report := launcherbackend.BackendTerminalReport{ RunID: spec.RunID, @@ -125,7 +228,7 @@ func buildTerminalReport(spec launcherbackend.BackendLaunchSpec, receipt launche FallbackPosture: launcherbackend.BackendFallbackPostureNoAutomaticFallback, TerminatedAt: time.Now().UTC().Format(time.RFC3339), } - if helloSeen { + if strings.TrimSpace(errText) == "" && helloSeen { report.TerminationKind = launcherbackend.BackendTerminationKindCompleted return report } @@ -134,6 +237,10 @@ func buildTerminalReport(spec launcherbackend.BackendLaunchSpec, receipt launche report.FailureReasonCode = launcherbackend.BackendErrorCodeWatchdogTimeout return report } + if errText == launcherbackend.BackendErrorCodeHandshakeFailed { + report.FailureReasonCode = launcherbackend.BackendErrorCodeHandshakeFailed + return report + } report.FailureReasonCode = launcherbackend.BackendErrorCodeHypervisorLaunchFailed return report } diff --git a/internal/launcherdaemon/qemu_runtime_linux_test.go b/internal/launcherdaemon/qemu_runtime_linux_test.go new file mode 100644 index 00000000..0eea748d --- /dev/null +++ b/internal/launcherdaemon/qemu_runtime_linux_test.go @@ -0,0 +1,167 @@ +//go:build linux + +package launcherdaemon + +import ( + "context" + "os/exec" + "sync" + "testing" + "time" + + "github.com/runecode-ai/runecode/internal/launcherbackend" +) + +func TestQEMUMonitorInstanceKillsAndReapsOnRuntimeMaterialParseFailure(t *testing.T) { + controller := &qemuController{cfg: QEMUControllerConfig{}, instances: map[string]*qemuInstance{}} + spec := validSpecForTests() + ref := InstanceRef{RunID: spec.RunID, StageID: spec.StageID, RoleInstanceID: spec.RoleInstanceID} + cmd, stdout := qemuParseFailureCommand(t) + inst := registerQEMUTestInstance(controller, ref, cmd) + waitForQEMUMonitor(t, controller, inst, preparedLaunchState{ + stdout: stdout, + receipt: launcherbackend.BackendLaunchReceipt{RunID: spec.RunID, IsolateID: "iso-1", SessionID: "session-1"}, + spec: spec, + cmd: cmd, + }) + assertQEMUParseFailureCleanup(t, controller, inst, ref, cmd) +} + +func TestQEMURecordHelloLineSynchronizesStateMutation(t *testing.T) { + controller := &qemuController{cfg: QEMUControllerConfig{}, instances: map[string]*qemuInstance{}} + ref := InstanceRef{RunID: "run-1", StageID: "stage-1", RoleInstanceID: "role-1"} + inst := &qemuInstance{ref: ref, state: InstanceState{Ref: ref, Active: true}} + controller.mu.Lock() + controller.instances[instanceKey(ref)] = inst + controller.mu.Unlock() + + const workers = 32 + const iterations = 64 + runConcurrentHelloStateWork(t, controller, inst, ref, workers, iterations) + assertHelloStateVisible(t, controller, inst, ref) +} + +func TestRecordHelloLineRejectsSubstringMatch(t *testing.T) { + controller := &qemuController{cfg: QEMUControllerConfig{}, instances: map[string]*qemuInstance{}} + inst := &qemuInstance{state: InstanceState{Active: true}} + if recordHelloLine(controller, inst, "boot "+helloWorldToken) { + t.Fatal("recordHelloLine accepted substring match, want exact hello line only") + } +} + +func qemuParseFailureCommand(t *testing.T) (*exec.Cmd, launchStateStdout) { + t.Helper() + cmd := exec.Command("sh", "-c", "printf 'RUNE_POST_HANDSHAKE_MATERIAL=!!!\\n'; sleep 30") + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("StdoutPipe returned error: %v", err) + } + cmd.Stderr = cmd.Stdout + if err := cmd.Start(); err != nil { + t.Fatalf("Start returned error: %v", err) + } + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + return cmd, stdout +} + +func registerQEMUTestInstance(controller *qemuController, ref InstanceRef, cmd *exec.Cmd) *qemuInstance { + inst := &qemuInstance{ref: ref, state: InstanceState{Ref: ref, Active: true}, cmd: cmd, updates: make(chan RuntimeUpdate, 16)} + controller.mu.Lock() + controller.instances[instanceKey(ref)] = inst + controller.mu.Unlock() + return inst +} + +func waitForQEMUMonitor(t *testing.T, controller *qemuController, inst *qemuInstance, launchState preparedLaunchState) { + t.Helper() + done := make(chan struct{}) + go func() { + defer close(done) + controller.monitorInstance(context.Background(), inst, launchState) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("monitorInstance did not return after parse failure") + } +} + +func assertQEMUParseFailureCleanup(t *testing.T, controller *qemuController, inst *qemuInstance, ref InstanceRef, cmd *exec.Cmd) { + t.Helper() + if cmd.ProcessState == nil { + t.Fatal("process state is nil, want reaped process after parse failure") + } + lastLifecycle := lastQEMULifecycleUpdate(inst.updates) + if lastLifecycle == nil || lastLifecycle.BackendLifecycle == nil { + t.Fatal("missing terminal lifecycle update") + } + if got, want := lastLifecycle.BackendLifecycle.CurrentState, launcherbackend.BackendLifecycleStateTerminated; got != want { + t.Fatalf("terminal lifecycle current state = %q, want %q", got, want) + } + controller.mu.RLock() + _, stillTracked := controller.instances[instanceKey(ref)] + controller.mu.RUnlock() + if stillTracked { + t.Fatal("instance still tracked after terminal cleanup") + } +} + +func lastQEMULifecycleUpdate(updates <-chan RuntimeUpdate) *launcherbackend.RuntimeLifecycleState { + var lastLifecycle *launcherbackend.RuntimeLifecycleState + for update := range updates { + if update.Lifecycle != nil { + lastLifecycle = update.Lifecycle + } + } + return lastLifecycle +} + +func runConcurrentHelloStateWork(t *testing.T, controller *qemuController, inst *qemuInstance, ref InstanceRef, workers, iterations int) { + t.Helper() + var wg sync.WaitGroup + wg.Add(workers) + for i := range workers { + go func(i int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + runHelloStateWorkerIteration(t, controller, inst, ref, i) + } + }(i) + } + wg.Wait() +} + +func runHelloStateWorkerIteration(t *testing.T, controller *qemuController, inst *qemuInstance, ref InstanceRef, worker int) { + t.Helper() + if worker%2 == 0 { + if !recordHelloLine(controller, inst, helloWorldToken) { + t.Error("recordHelloLine did not accept hello token line") + } + return + } + if _, err := controller.GetState(context.Background(), ref); err != nil { + t.Errorf("GetState returned error: %v", err) + } +} + +func assertHelloStateVisible(t *testing.T, controller *qemuController, inst *qemuInstance, ref InstanceRef) { + t.Helper() + state, err := controller.GetState(context.Background(), ref) + if err != nil { + t.Fatalf("GetState returned error: %v", err) + } + if !state.HelloWorldSeen { + t.Fatal("HelloWorldSeen = false, want true after hello output") + } + controller.mu.RLock() + helloSeen := inst.helloSeen + controller.mu.RUnlock() + if !helloSeen { + t.Fatal("inst.helloSeen = false, want true after hello output") + } +} diff --git a/internal/launcherdaemon/runtime_attestation_support.go b/internal/launcherdaemon/runtime_attestation_support.go index 174da231..23e42d00 100644 --- a/internal/launcherdaemon/runtime_attestation_support.go +++ b/internal/launcherdaemon/runtime_attestation_support.go @@ -2,93 +2,212 @@ package launcherdaemon import ( "fmt" - "reflect" - "sort" - "time" "github.com/runecode-ai/runecode/internal/launcherbackend" ) -const trustedRuntimeAttestationVerifierPolicyID = "runtime_asset_admission_identity" - func populateRuntimeSessionBinding(receipt *launcherbackend.BackendLaunchReceipt, binding runtimeSessionBinding) { if receipt == nil { return } - receipt.ProvisioningPosture = launcherbackend.ProvisioningPostureAttested + receipt.ProvisioningPosture = launcherbackend.ProvisioningPostureTOFU receipt.IsolateID = binding.IsolateID receipt.SessionID = binding.SessionID receipt.SessionNonce = binding.SessionNonce - receipt.LaunchContextDigest = binding.LaunchContextDigest - receipt.HandshakeTranscriptHash = binding.HandshakeTranscriptHash - receipt.IsolateSessionKeyIDValue = binding.IsolateSessionKeyIDValue } -func applyTrustedRuntimeAttestation(receipt *launcherbackend.BackendLaunchReceipt, admission launcherbackend.RuntimeAdmissionRecord, now time.Time) error { - expectedMeasurementDigests, err := canonicalTrustedRuntimeMeasurementDigests(receipt, admission) +func buildPostHandshakeAttestationProgress(receipt launcherbackend.BackendLaunchReceipt, admission launcherbackend.RuntimeAdmissionRecord) (*launcherbackend.PostHandshakeRuntimeAttestationInput, error) { + return buildPostHandshakeAttestationProgressFromMaterial(receipt, admission, nil) +} + +func buildPostHandshakeAttestationProgressFromMaterial(receipt launcherbackend.BackendLaunchReceipt, admission launcherbackend.RuntimeAdmissionRecord, material *launcherbackend.RuntimePostHandshakeMaterial) (*launcherbackend.PostHandshakeRuntimeAttestationInput, error) { + if receipt.RunID == "" { + return nil, fmt.Errorf("receipt is required") + } + input, err := collectPostHandshakeRuntimeAttestationInput(&receipt, admission, material) if err != nil { - return err + return nil, err } - receipt.BootComponentDigests = componentDigestValues(receipt.BootComponentDigestByName) - receipt.AttestationEvidenceSourceKind = launcherbackend.AttestationSourceKindTrustedRuntime - receipt.AttestationMeasurementProfile = admission.AttestationMeasurementProfile - receipt.AttestationFreshnessMaterial = []string{"session_nonce"} - receipt.AttestationFreshnessBindingClaims = []string{"session_nonce", "handshake_transcript_hash", "launch_context_digest"} - receipt.AttestationEvidenceClaimsDigest = expectedMeasurementDigests[0] - receipt.AttestationVerifierPolicyID = trustedRuntimeAttestationVerifierPolicyID - if receipt.AuthorityStateDigest != "" { - receipt.AttestationVerifierPolicyDigest = receipt.AuthorityStateDigest - } else { - receipt.AttestationVerifierPolicyDigest = admission.RuntimeImageVerifierSetRef - } - receipt.AttestationVerificationRulesVersion = "trusted-runtime-v1" - receipt.AttestationVerificationTimestamp = now.UTC().Format(time.RFC3339) - receipt.AttestationVerificationResult = launcherbackend.AttestationVerificationResultValid - receipt.AttestationReplayVerdict = launcherbackend.AttestationReplayVerdictOriginal + return input, nil +} + +func recordValidatedSecureSession(receipt *launcherbackend.BackendLaunchReceipt, summary launcherbackend.SecureSessionSummary, launchContextDigest string) error { + if receipt == nil { + return fmt.Errorf("receipt is required") + } + receipt.IsolateID = summary.BindingRecord.IsolateID + receipt.SessionID = summary.BindingRecord.SessionID + receipt.SessionNonce = summary.BindingRecord.SessionNonce + receipt.LaunchContextDigest = launchContextDigest + receipt.HandshakeTranscriptHash = summary.BindingRecord.HandshakeTranscriptHash + receipt.IsolateSessionKeyIDValue = summary.BindingRecord.IsolateKeyIDValue + receipt.SessionSecurity = &summary.SecurityPosture + receipt.ProvisioningPosture = summary.BindingRecord.ProvisioningMode + receipt.AttestationVerificationResult = launcherbackend.AttestationVerificationResultUnknown + receipt.AttestationReplayVerdict = launcherbackend.AttestationReplayVerdictUnknown receipt.AttestationVerificationReasonCodes = nil + receipt.AttestationVerificationTimestamp = "" return nil } -func canonicalTrustedRuntimeMeasurementDigests(receipt *launcherbackend.BackendLaunchReceipt, admission launcherbackend.RuntimeAdmissionRecord) ([]string, error) { - if receipt == nil { - return nil, nil +func validateSecureSessionAndBuildSummary(receipt launcherbackend.BackendLaunchReceipt, secureSession *launcherbackend.RuntimeSecureSessionMaterial) (launcherbackend.SecureSessionSummary, string, error) { + if secureSession == nil { + return launcherbackend.SecureSessionSummary{}, "", fmt.Errorf("runtime secure-session material is required") } - if receipt.RuntimeImageDescriptorDigest == "" || receipt.RuntimeImageBootProfile == "" { - return nil, fmt.Errorf("runtime identity is required before attestation") + if receipt.RunID == "" || receipt.IsolateID == "" || receipt.SessionID == "" || receipt.SessionNonce == "" { + return launcherbackend.SecureSessionSummary{}, "", fmt.Errorf("session binding is required before secure session validation") } - if receipt.IsolateID == "" || receipt.SessionID == "" || receipt.SessionNonce == "" || receipt.LaunchContextDigest == "" || receipt.HandshakeTranscriptHash == "" || receipt.IsolateSessionKeyIDValue == "" { - return nil, fmt.Errorf("session binding is required before attestation") + binding, err := launcherbackend.ValidateSessionHandshake(secureSession.LaunchContext, secureSession.HostHello, secureSession.IsolateHello, secureSession.SessionReady, nil) + if err != nil { + return launcherbackend.SecureSessionSummary{}, "", fmt.Errorf("secure session validation failed: %w", err) } - if admission.AttestationMeasurementProfile == "" || len(admission.AttestationExpectedMeasurementDigests) == 0 { - return nil, fmt.Errorf("admitted attestation expectations are required") + if err := validateSecureSessionSummaryBinding(receipt, binding); err != nil { + return launcherbackend.SecureSessionSummary{}, "", err } - expectedMeasurementDigests, err := launcherbackend.DeriveExpectedMeasurementDigests(admission.AttestationMeasurementProfile, admission.BootContractVersion, admission.ComponentDigests) + summary, err := launcherbackend.BuildSecureSessionSummary(secureSession.HostHello, secureSession.IsolateHello, secureSession.SessionReady, binding) if err != nil { - return nil, err + return launcherbackend.SecureSessionSummary{}, "", fmt.Errorf("secure session summary failed: %w", err) + } + return summary, secureSession.LaunchContext.LaunchContextDigest, nil +} + +func validateSecureSessionSummaryBinding(receipt launcherbackend.BackendLaunchReceipt, binding launcherbackend.SessionBindingRecord) error { + if binding.RunID != receipt.RunID || binding.IsolateID != receipt.IsolateID || binding.SessionID != receipt.SessionID || binding.SessionNonce != receipt.SessionNonce { + return fmt.Errorf("secure session material must bind to launch receipt session tuple") } - if !reflect.DeepEqual(expectedMeasurementDigests, admission.AttestationExpectedMeasurementDigests) { - return nil, fmt.Errorf("admitted attestation expectations do not match canonical runtime identity") + return nil +} + +func secureSessionTransportKind(value string) string { + switch value { + case launcherbackend.TransportKindVSock, launcherbackend.TransportKindVirtioSerial: + return value + default: + return launcherbackend.TransportKindVSock } - return expectedMeasurementDigests, nil } -func componentDigestValues(values map[string]string) []string { - if len(values) == 0 { +func validateRuntimeReportedAttestationBinding(receipt *launcherbackend.BackendLaunchReceipt, input *launcherbackend.PostHandshakeRuntimeAttestationInput) error { + if receipt == nil || input == nil { return nil } - out := make([]string, 0, len(values)) - for _, value := range values { - out = append(out, value) + if err := validateRuntimeReportedRequiredFields(input); err != nil { + return err } - sort.Strings(out) - unique := out[:0] - for _, value := range out { - if value == "" { - continue - } - if len(unique) == 0 || unique[len(unique)-1] != value { - unique = append(unique, value) + if err := validateRuntimeReportedSessionTuple(receipt, input); err != nil { + return err + } + return validateRuntimeReportedIdentity(receipt, input) +} + +func validateRuntimeReportedRequiredFields(input *launcherbackend.PostHandshakeRuntimeAttestationInput) error { + if input == nil || !input.RuntimeEvidenceCollected { + return nil + } + if input.RunID == "" || input.IsolateID == "" || input.SessionID == "" || input.SessionNonce == "" || input.LaunchContextDigest == "" || input.HandshakeTranscriptHash == "" || input.IsolateSessionKeyIDValue == "" { + return fmt.Errorf("runtime-reported attestation input must include full validated session binding when runtime evidence is collected") + } + if input.RuntimeImageDescriptorDigest == "" || input.RuntimeImageBootProfile == "" { + return fmt.Errorf("runtime-reported attestation input must include admitted runtime identity when runtime evidence is collected") + } + return nil +} + +func validateRuntimeReportedSessionTuple(receipt *launcherbackend.BackendLaunchReceipt, input *launcherbackend.PostHandshakeRuntimeAttestationInput) error { + checks := []struct { + value string + want string + msg string + }{ + {input.RunID, receipt.RunID, "runtime-reported attestation input must bind to launch receipt run_id"}, + {input.IsolateID, receipt.IsolateID, "runtime-reported attestation input must bind to launch receipt isolate_id"}, + {input.SessionID, receipt.SessionID, "runtime-reported attestation input must bind to launch receipt session_id"}, + {input.SessionNonce, receipt.SessionNonce, "runtime-reported attestation input must bind to launch receipt session_nonce"}, + {input.LaunchContextDigest, receipt.LaunchContextDigest, "runtime-reported attestation input must bind to launch receipt launch_context_digest"}, + {input.HandshakeTranscriptHash, receipt.HandshakeTranscriptHash, "runtime-reported attestation input must bind to launch receipt handshake_transcript_hash"}, + {input.IsolateSessionKeyIDValue, receipt.IsolateSessionKeyIDValue, "runtime-reported attestation input must bind to launch receipt isolate_session_key_id_value"}, + } + for _, check := range checks { + if check.value != "" && check.value != check.want { + return fmt.Errorf("%s", check.msg) } } - return append([]string{}, unique...) + return nil +} + +func validateRuntimeReportedIdentity(receipt *launcherbackend.BackendLaunchReceipt, input *launcherbackend.PostHandshakeRuntimeAttestationInput) error { + if input.RuntimeImageDescriptorDigest != "" && input.RuntimeImageDescriptorDigest != receipt.RuntimeImageDescriptorDigest { + return fmt.Errorf("runtime-reported attestation input must bind to admitted runtime image descriptor") + } + if input.RuntimeImageBootProfile != "" && input.RuntimeImageBootProfile != receipt.RuntimeImageBootProfile { + return fmt.Errorf("runtime-reported attestation input must bind to admitted runtime image boot profile") + } + return nil +} + +func recordPostHandshakeAttestationProgress(receipt *launcherbackend.BackendLaunchReceipt, input *launcherbackend.PostHandshakeRuntimeAttestationInput) error { + if receipt == nil { + return fmt.Errorf("receipt is required") + } + normalizedInput := launcherbackend.NormalizePostHandshakeRuntimeAttestationInput(input) + if normalizedInput == nil { + return fmt.Errorf("post-handshake runtime attestation input is required") + } + if err := validatePostHandshakeAttestationInputBinding(receipt, normalizedInput); err != nil { + return err + } + receipt.BootComponentDigests = componentDigestValues(receipt.BootComponentDigestByName) + receipt.AttestationEvidenceSourceKind = normalizedInput.AttestationSourceKind + receipt.AttestationMeasurementProfile = normalizedInput.MeasurementProfile + receipt.AttestationFreshnessMaterial = append([]string{}, normalizedInput.FreshnessMaterial...) + receipt.AttestationFreshnessBindingClaims = append([]string{}, normalizedInput.FreshnessBindingClaims...) + receipt.AttestationEvidenceClaimsDigest = normalizedInput.EvidenceClaimsDigest + receipt.AttestationVerifierPolicyID = "" + receipt.AttestationVerifierPolicyDigest = "" + receipt.AttestationVerificationRulesVersion = "" + receipt.AttestationVerificationTimestamp = "" + receipt.AttestationVerificationResult = launcherbackend.AttestationVerificationResultUnknown + receipt.AttestationReplayVerdict = launcherbackend.AttestationReplayVerdictUnknown + receipt.AttestationVerificationReasonCodes = nil + return nil +} + +func validatePostHandshakeAttestationInputBinding(receipt *launcherbackend.BackendLaunchReceipt, input *launcherbackend.PostHandshakeRuntimeAttestationInput) error { + if receipt == nil || input == nil { + return fmt.Errorf("post-handshake runtime attestation input is required") + } + if input.RunID == "" || input.IsolateID == "" || input.SessionID == "" || input.SessionNonce == "" || input.LaunchContextDigest == "" || input.HandshakeTranscriptHash == "" || input.IsolateSessionKeyIDValue == "" { + return fmt.Errorf("session binding is required before attestation") + } + if input.RuntimeImageDescriptorDigest == "" || input.RuntimeImageBootProfile == "" { + return fmt.Errorf("runtime identity is required before attestation") + } + if input.RunID != receipt.RunID || input.IsolateID != receipt.IsolateID || input.SessionID != receipt.SessionID || input.SessionNonce != receipt.SessionNonce || input.LaunchContextDigest != receipt.LaunchContextDigest || input.HandshakeTranscriptHash != receipt.HandshakeTranscriptHash || input.IsolateSessionKeyIDValue != receipt.IsolateSessionKeyIDValue { + return fmt.Errorf("post-handshake attestation input must bind to validated live session tuple") + } + if input.RuntimeImageDescriptorDigest != receipt.RuntimeImageDescriptorDigest || input.RuntimeImageBootProfile != receipt.RuntimeImageBootProfile { + return fmt.Errorf("post-handshake attestation input must bind to admitted runtime identity") + } + return nil +} + +func runtimePostHandshakeFactsUpdate(runID string, receipt launcherbackend.BackendLaunchReceipt, admission launcherbackend.RuntimeAdmissionRecord, hardening launcherbackend.AppliedHardeningPosture, material *launcherbackend.RuntimePostHandshakeMaterial) (RuntimeUpdate, error) { + if material == nil || material.SecureSession == nil { + return RuntimeUpdate{}, backendError(launcherbackend.BackendErrorCodeHandshakeFailed, "runtime secure-session material not provided") + } + summary, launchContextDigest, err := validateSecureSessionAndBuildSummary(receipt, material.SecureSession) + if err != nil { + return RuntimeUpdate{}, err + } + if err := recordValidatedSecureSession(&receipt, summary, launchContextDigest); err != nil { + return RuntimeUpdate{}, err + } + postHandshake, err := buildPostHandshakeAttestationProgressFromMaterial(receipt, admission, material) + if err != nil { + return RuntimeUpdate{}, err + } + if err := recordPostHandshakeAttestationProgress(&receipt, postHandshake); err != nil { + return RuntimeUpdate{}, err + } + return RuntimeUpdate{RunID: runID, Facts: &launcherbackend.RuntimeFactsSnapshot{LaunchReceipt: receipt, PostHandshakeAttestationInput: postHandshake, HardeningPosture: hardening}}, nil } diff --git a/internal/launcherdaemon/runtime_attestation_support_handshake.go b/internal/launcherdaemon/runtime_attestation_support_handshake.go new file mode 100644 index 00000000..c69909b5 --- /dev/null +++ b/internal/launcherdaemon/runtime_attestation_support_handshake.go @@ -0,0 +1,144 @@ +package launcherdaemon + +import ( + "crypto/ed25519" + "encoding/base64" + "fmt" + + "github.com/runecode-ai/runecode/internal/launcherbackend" +) + +type runtimeSecureSessionHandshakeTuple struct { + launchContext launcherbackend.LaunchContext + host launcherbackend.HostHello + isolate launcherbackend.IsolateHello + ready launcherbackend.SessionReady +} + +func secureSessionHandshakeTuple(spec launcherbackend.BackendLaunchSpec, receipt launcherbackend.BackendLaunchReceipt) (runtimeSecureSessionHandshakeTuple, string, error) { + isolateID, sessionID, sessionNonce, err := secureSessionBindingTuple(receipt) + if err != nil { + return runtimeSecureSessionHandshakeTuple{}, "", err + } + launchContext, launchContextDigest, err := secureSessionLaunchContext(spec, sessionID, sessionNonce) + if err != nil { + return runtimeSecureSessionHandshakeTuple{}, "", err + } + host := secureSessionHostHello(spec, receipt, isolateID, sessionID, sessionNonce, launchContextDigest) + isolate, keyIDValue, privateKey := secureSessionIsolateHello(spec, receipt, isolateID, sessionID, sessionNonce, launchContextDigest) + handshakeTranscriptHash, err := signSecureSessionProof(&isolate, host, privateKey) + if err != nil { + return runtimeSecureSessionHandshakeTuple{}, "", err + } + ready := secureSessionReady(spec, isolateID, sessionID, sessionNonce, keyIDValue, handshakeTranscriptHash) + return runtimeSecureSessionHandshakeTuple{ + launchContext: launchContext, + host: host, + isolate: isolate, + ready: ready, + }, launchContextDigest, nil +} + +func secureSessionBindingTuple(receipt launcherbackend.BackendLaunchReceipt) (string, string, string, error) { + isolateID := receipt.IsolateID + sessionID := receipt.SessionID + sessionNonce := receipt.SessionNonce + if isolateID == "" || sessionID == "" || sessionNonce == "" { + return "", "", "", fmt.Errorf("session binding is required before secure session validation") + } + return isolateID, sessionID, sessionNonce, nil +} + +func secureSessionLaunchContext(spec launcherbackend.BackendLaunchSpec, sessionID, sessionNonce string) (launcherbackend.LaunchContext, string, error) { + launchContext := launcherbackend.LaunchContext{ + RunID: spec.RunID, + StageID: spec.StageID, + RoleInstanceID: spec.RoleInstanceID, + SessionID: sessionID, + SessionNonce: sessionNonce, + } + launchContextDigest, err := launchContext.CanonicalDigest() + if err != nil { + return launcherbackend.LaunchContext{}, "", err + } + launchContext.LaunchContextDigest = launchContextDigest + return launchContext, launchContextDigest, nil +} + +func secureSessionHostHello(spec launcherbackend.BackendLaunchSpec, receipt launcherbackend.BackendLaunchReceipt, isolateID, sessionID, sessionNonce, launchContextDigest string) launcherbackend.HostHello { + return launcherbackend.HostHello{ + RunID: spec.RunID, + StageID: spec.StageID, + RoleInstanceID: spec.RoleInstanceID, + IsolateID: isolateID, + SessionID: sessionID, + SessionNonce: sessionNonce, + LaunchContextDigest: launchContextDigest, + TransportKind: secureSessionTransportKind(receipt.TransportKind), + TransportRequirements: launcherbackend.SessionTransportRequirements{ + MutualAuthenticationRequired: true, + EncryptionRequired: true, + ReplayProtectionRequired: true, + }, + Framing: launcherbackend.SessionFramingContract{ + FrameFormat: launcherbackend.SessionFramingLengthPrefixedV1, + MaxFrameBytes: launcherbackend.SessionMaxFrameBytesHardLimit, + MaxHandshakeMessageBytes: launcherbackend.SessionMaxHandshakeMessageBytesHardLimit, + }, + } +} + +func secureSessionIsolateHello(spec launcherbackend.BackendLaunchSpec, receipt launcherbackend.BackendLaunchReceipt, isolateID, sessionID, sessionNonce, launchContextDigest string) (launcherbackend.IsolateHello, string, ed25519.PrivateKey) { + keyIDValue, publicKey, privateKey := deriveSyntheticSecureSessionKeyPair(spec, receipt) + return launcherbackend.IsolateHello{ + RunID: spec.RunID, + IsolateID: isolateID, + SessionID: sessionID, + SessionNonce: sessionNonce, + LaunchContextDigest: launchContextDigest, + IsolateSessionKey: launcherbackend.IsolateSessionKey{ + Alg: "ed25519", + KeyID: "runtime-session-key", + KeyIDValue: keyIDValue, + PublicKeyEncoding: "base64", + PublicKey: base64.StdEncoding.EncodeToString(publicKey), + KeyOrigin: launcherbackend.SessionKeyOriginIsolateBoundaryEphemeral, + }, + ProofOfPossession: launcherbackend.SessionKeyProof{ + Alg: "ed25519", + KeyID: "runtime-session-key", + KeyIDValue: keyIDValue, + }, + }, keyIDValue, privateKey +} + +func signSecureSessionProof(isolate *launcherbackend.IsolateHello, host launcherbackend.HostHello, privateKey ed25519.PrivateKey) (string, error) { + handshakeTranscriptHash, err := launcherbackend.HandshakeTranscriptHash(host, *isolate) + if err != nil { + return "", err + } + isolate.HandshakeTranscriptHash = handshakeTranscriptHash + payload, err := secureSessionProofPayload(host, *isolate, handshakeTranscriptHash) + if err != nil { + return "", err + } + isolate.ProofOfPossession.Signature = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, payload)) + return handshakeTranscriptHash, nil +} + +func secureSessionReady(spec launcherbackend.BackendLaunchSpec, isolateID, sessionID, sessionNonce, keyIDValue, handshakeTranscriptHash string) launcherbackend.SessionReady { + return launcherbackend.SessionReady{ + RunID: spec.RunID, + IsolateID: isolateID, + SessionID: sessionID, + SessionNonce: sessionNonce, + ProvisioningMode: launcherbackend.ProvisioningPostureTOFU, + IdentityBindingPosture: launcherbackend.ProvisioningPostureTOFU, + IsolateKeyIDValue: keyIDValue, + HandshakeTranscriptHash: handshakeTranscriptHash, + ChannelKeyMode: launcherbackend.SessionChannelKeyModeDistinct, + MutuallyAuthenticated: true, + Encrypted: true, + ProofOfPossessionVerified: true, + } +} diff --git a/internal/launcherdaemon/runtime_attestation_support_helpers.go b/internal/launcherdaemon/runtime_attestation_support_helpers.go new file mode 100644 index 00000000..1987b36e --- /dev/null +++ b/internal/launcherdaemon/runtime_attestation_support_helpers.go @@ -0,0 +1,120 @@ +package launcherdaemon + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "reflect" + "sort" + + "github.com/runecode-ai/runecode/internal/launcherbackend" + "github.com/runecode-ai/runecode/third_party/jsoncanonicalizer" +) + +func deriveSyntheticSecureSessionKeyPair(spec launcherbackend.BackendLaunchSpec, receipt launcherbackend.BackendLaunchReceipt) (string, ed25519.PublicKey, ed25519.PrivateKey) { + seedHash := sha256.Sum256(syntheticHashInput("secure-session-key", spec.RunID, spec.StageID, spec.RoleInstanceID, receipt.IsolateID, receipt.SessionID, receipt.SessionNonce, receipt.RuntimeImageDescriptorDigest)) + privateKey := ed25519.NewKeyFromSeed(seedHash[:]) + publicKey := privateKey.Public().(ed25519.PublicKey) + publicKeyHash := sha256.Sum256(publicKey) + return hex.EncodeToString(publicKeyHash[:]), publicKey, privateKey +} + +func secureSessionProofPayload(host launcherbackend.HostHello, isolate launcherbackend.IsolateHello, transcriptHash string) ([]byte, error) { + payload := struct { + Schema string `json:"schema"` + RunID string `json:"run_id"` + IsolateID string `json:"isolate_id"` + SessionID string `json:"session_id"` + SessionNonce string `json:"session_nonce"` + LaunchContextDigest string `json:"launch_context_digest"` + HandshakeTranscript string `json:"handshake_transcript_hash"` + TransportKind string `json:"transport_kind"` + KeyID string `json:"key_id"` + KeyIDValue string `json:"key_id_value"` + ChannelKeyMode string `json:"channel_key_mode"` + IdentityKeySeparation bool `json:"identity_key_separation"` + }{ + Schema: "runecode.secure_session_proof_payload.v1", + RunID: host.RunID, + IsolateID: host.IsolateID, + SessionID: host.SessionID, + SessionNonce: host.SessionNonce, + LaunchContextDigest: host.LaunchContextDigest, + HandshakeTranscript: transcriptHash, + TransportKind: host.TransportKind, + KeyID: isolate.IsolateSessionKey.KeyID, + KeyIDValue: isolate.IsolateSessionKey.KeyIDValue, + ChannelKeyMode: launcherbackend.SessionChannelKeyModeDistinct, + IdentityKeySeparation: true, + } + raw, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return jsoncanonicalizer.Transform(raw) +} + +func canonicalTrustedRuntimeMeasurementDigests(receipt *launcherbackend.BackendLaunchReceipt, admission launcherbackend.RuntimeAdmissionRecord) ([]string, error) { + if receipt == nil { + return nil, nil + } + if receipt.RuntimeImageDescriptorDigest == "" || receipt.RuntimeImageBootProfile == "" { + return nil, fmt.Errorf("runtime identity is required before attestation") + } + if receipt.IsolateID == "" || receipt.SessionID == "" || receipt.SessionNonce == "" || receipt.LaunchContextDigest == "" || receipt.HandshakeTranscriptHash == "" || receipt.IsolateSessionKeyIDValue == "" { + return nil, fmt.Errorf("session binding is required before attestation") + } + if admission.AttestationMeasurementProfile == "" || len(admission.AttestationExpectedMeasurementDigests) == 0 { + return nil, fmt.Errorf("admitted attestation expectations are required") + } + expectedMeasurementDigests, err := launcherbackend.DeriveExpectedMeasurementDigests(admission.AttestationMeasurementProfile, admission.BootContractVersion, admission.ComponentDigests) + if err != nil { + return nil, err + } + if !reflect.DeepEqual(expectedMeasurementDigests, admission.AttestationExpectedMeasurementDigests) { + return nil, fmt.Errorf("admitted attestation expectations do not match canonical runtime identity") + } + return expectedMeasurementDigests, nil +} + +func componentDigestValues(values map[string]string) []string { + if len(values) == 0 { + return nil + } + out := make([]string, 0, len(values)) + for _, value := range values { + out = append(out, value) + } + sort.Strings(out) + unique := out[:0] + for _, value := range out { + if value == "" { + continue + } + if len(unique) == 0 || unique[len(unique)-1] != value { + unique = append(unique, value) + } + } + return append([]string{}, unique...) +} + +func cloneMap(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func canonicalJSONBytes(value any) ([]byte, error) { + raw, err := json.Marshal(value) + if err != nil { + return nil, err + } + return jsoncanonicalizer.Transform(raw) +} diff --git a/internal/launcherdaemon/runtime_attestation_support_input.go b/internal/launcherdaemon/runtime_attestation_support_input.go new file mode 100644 index 00000000..f7073664 --- /dev/null +++ b/internal/launcherdaemon/runtime_attestation_support_input.go @@ -0,0 +1,81 @@ +package launcherdaemon + +import ( + "fmt" + + "github.com/runecode-ai/runecode/internal/launcherbackend" +) + +func collectPostHandshakeRuntimeAttestationInput(receipt *launcherbackend.BackendLaunchReceipt, admission launcherbackend.RuntimeAdmissionRecord, material *launcherbackend.RuntimePostHandshakeMaterial) (*launcherbackend.PostHandshakeRuntimeAttestationInput, error) { + expectedMeasurementDigests, err := canonicalTrustedRuntimeMeasurementDigests(receipt, admission) + if err != nil { + return nil, err + } + runtimeInput := normalizedRuntimeAttestationInput(material) + if err := validateRuntimeReportedAttestationBinding(receipt, runtimeInput); err != nil { + return nil, err + } + input := basePostHandshakeAttestationInput(receipt, runtimeInput) + copyRuntimeAttestationDetails(input, runtimeInput) + if err := validateRuntimeEvidenceClaims(input, expectedMeasurementDigests); err != nil { + return nil, err + } + return input, nil +} + +func normalizedRuntimeAttestationInput(material *launcherbackend.RuntimePostHandshakeMaterial) *launcherbackend.PostHandshakeRuntimeAttestationInput { + if material == nil { + return nil + } + return launcherbackend.NormalizePostHandshakeRuntimeAttestationInput(material.Attestation) +} + +func basePostHandshakeAttestationInput(receipt *launcherbackend.BackendLaunchReceipt, runtimeInput *launcherbackend.PostHandshakeRuntimeAttestationInput) *launcherbackend.PostHandshakeRuntimeAttestationInput { + return &launcherbackend.PostHandshakeRuntimeAttestationInput{ + RunID: receipt.RunID, + IsolateID: receipt.IsolateID, + SessionID: receipt.SessionID, + SessionNonce: receipt.SessionNonce, + RuntimeEvidenceCollected: runtimeInput != nil && runtimeInput.RuntimeEvidenceCollected, + LaunchContextDigest: receipt.LaunchContextDigest, + HandshakeTranscriptHash: receipt.HandshakeTranscriptHash, + IsolateSessionKeyIDValue: receipt.IsolateSessionKeyIDValue, + RuntimeImageDescriptorDigest: receipt.RuntimeImageDescriptorDigest, + RuntimeImageBootProfile: receipt.RuntimeImageBootProfile, + RuntimeImageVerifierRef: receipt.RuntimeImageVerifierRef, + AuthorityStateDigest: receipt.AuthorityStateDigest, + BootComponentDigestByName: cloneMap(receipt.BootComponentDigestByName), + BootComponentDigests: componentDigestValues(receipt.BootComponentDigestByName), + AttestationSourceKind: launcherbackend.AttestationSourceKindUnknown, + MeasurementProfile: launcherbackend.MeasurementProfileUnknown, + VerificationResult: launcherbackend.AttestationVerificationResultUnknown, + ReplayVerdict: launcherbackend.AttestationReplayVerdictUnknown, + } +} + +func copyRuntimeAttestationDetails(input *launcherbackend.PostHandshakeRuntimeAttestationInput, runtimeInput *launcherbackend.PostHandshakeRuntimeAttestationInput) { + if input == nil || runtimeInput == nil { + return + } + input.AttestationSourceKind = runtimeInput.AttestationSourceKind + input.MeasurementProfile = runtimeInput.MeasurementProfile + input.FreshnessMaterial = append([]string{}, runtimeInput.FreshnessMaterial...) + input.FreshnessBindingClaims = append([]string{}, runtimeInput.FreshnessBindingClaims...) + input.EvidenceClaimsDigest = runtimeInput.EvidenceClaimsDigest +} + +func validateRuntimeEvidenceClaims(input *launcherbackend.PostHandshakeRuntimeAttestationInput, expectedMeasurementDigests []string) error { + if input == nil || !input.RuntimeEvidenceCollected { + return nil + } + if input.AttestationSourceKind == launcherbackend.AttestationSourceKindUnknown || input.MeasurementProfile == launcherbackend.MeasurementProfileUnknown { + return fmt.Errorf("runtime-reported attestation source and measurement profile are required when runtime evidence is collected") + } + if input.EvidenceClaimsDigest == "" { + return fmt.Errorf("runtime-reported evidence_claims_digest is required when runtime evidence is collected") + } + if input.EvidenceClaimsDigest != expectedMeasurementDigests[0] { + return fmt.Errorf("runtime-reported evidence_claims_digest must bind to admitted runtime identity") + } + return nil +} diff --git a/internal/launcherdaemon/runtime_attestation_support_test.go b/internal/launcherdaemon/runtime_attestation_support_test.go new file mode 100644 index 00000000..bf3183c4 --- /dev/null +++ b/internal/launcherdaemon/runtime_attestation_support_test.go @@ -0,0 +1,302 @@ +package launcherdaemon + +import ( + "strings" + "testing" + + "github.com/runecode-ai/runecode/internal/launcherbackend" +) + +func TestPopulateRuntimeSessionBindingDoesNotAwardAttestedPosture(t *testing.T) { + spec := validSpecForTests() + binding := mustDeriveRuntimeSessionBinding(t, spec, spec.Image.DescriptorDigest, "isolate-1", "session-1", strings.Repeat("a", 32)) + receipt := launcherbackend.BackendLaunchReceipt{ProvisioningPosture: launcherbackend.ProvisioningPostureUnknown} + + populateRuntimeSessionBinding(&receipt, binding) + + if got, want := receipt.ProvisioningPosture, launcherbackend.ProvisioningPostureTOFU; got != want { + t.Fatalf("provisioning posture = %q, want %q", got, want) + } + if receipt.LaunchContextDigest != "" || receipt.HandshakeTranscriptHash != "" || receipt.IsolateSessionKeyIDValue != "" { + t.Fatal("launch-time session binding must not claim validated secure-session fields") + } + if receipt.AttestationVerificationResult == launcherbackend.AttestationVerificationResultValid { + t.Fatal("populateRuntimeSessionBinding must not set attestation verification success") + } +} + +func TestRecordValidatedSecureSessionKeepsReceiptPostureAtTOFU(t *testing.T) { + spec, admission, receipt := runtimeAttestationReceiptFixtureForValidation(t) + if receipt.ProvisioningPosture != launcherbackend.ProvisioningPostureTOFU { + t.Fatalf("pre-validation provisioning posture = %q, want %q", receipt.ProvisioningPosture, launcherbackend.ProvisioningPostureTOFU) + } + if receipt.SessionSecurity != nil { + t.Fatal("session_security must be empty before runtime secure-session update") + } + material := mustRuntimeSecureSessionMaterialForTests(t, spec, receipt) + summary, launchContextDigest, err := validateSecureSessionAndBuildSummary(receipt, material) + if err != nil { + t.Fatalf("validateSecureSessionAndBuildSummary returned error: %v", err) + } + if err := recordValidatedSecureSession(&receipt, summary, launchContextDigest); err != nil { + t.Fatalf("recordValidatedSecureSession returned error: %v", err) + } + assertValidatedReceiptStillTOFU(t, receipt) + + postHandshakeInput, err := buildPostHandshakeAttestationProgress(receipt, admission) + if err != nil { + t.Fatalf("buildPostHandshakeAttestationProgress returned error: %v", err) + } + assertPostHandshakeInputUsesReceiptBinding(t, receipt, postHandshakeInput) +} + +func TestValidateSecureSessionAndBuildSummaryRejectsMissingRuntimeMaterial(t *testing.T) { + _, _, receipt := runtimeAttestationReceiptFixtureForValidation(t) + _, _, err := validateSecureSessionAndBuildSummary(receipt, nil) + if err == nil { + t.Fatal("validateSecureSessionAndBuildSummary expected missing runtime material error") + } + if !strings.Contains(err.Error(), "runtime secure-session material is required") { + t.Fatalf("error = %q, want missing runtime secure-session material", err.Error()) + } +} + +func TestBuildPostHandshakeAttestationProgressUsesRuntimeReportedEvidenceCollection(t *testing.T) { + spec, admission, receipt := runtimeAttestationReceiptFixtureForValidation(t) + secureSession := mustRuntimeSecureSessionMaterialForTests(t, spec, receipt) + summary, launchContextDigest, err := validateSecureSessionAndBuildSummary(receipt, secureSession) + if err != nil { + t.Fatalf("validateSecureSessionAndBuildSummary returned error: %v", err) + } + if err := recordValidatedSecureSession(&receipt, summary, launchContextDigest); err != nil { + t.Fatalf("recordValidatedSecureSession returned error: %v", err) + } + material := &launcherbackend.RuntimePostHandshakeMaterial{ + Attestation: &launcherbackend.PostHandshakeRuntimeAttestationInput{ + RunID: receipt.RunID, + IsolateID: receipt.IsolateID, + SessionID: receipt.SessionID, + SessionNonce: receipt.SessionNonce, + LaunchContextDigest: receipt.LaunchContextDigest, + HandshakeTranscriptHash: receipt.HandshakeTranscriptHash, + IsolateSessionKeyIDValue: receipt.IsolateSessionKeyIDValue, + RuntimeImageDescriptorDigest: receipt.RuntimeImageDescriptorDigest, + RuntimeImageBootProfile: receipt.RuntimeImageBootProfile, + RuntimeEvidenceCollected: true, + AttestationSourceKind: launcherbackend.AttestationSourceKindTrustedRuntime, + MeasurementProfile: admission.AttestationMeasurementProfile, + FreshnessMaterial: []string{"session_nonce"}, + FreshnessBindingClaims: []string{"session_nonce", "handshake_transcript_hash", "launch_context_digest"}, + EvidenceClaimsDigest: admission.AttestationExpectedMeasurementDigests[0], + }, + } + input, err := buildPostHandshakeAttestationProgressFromMaterial(receipt, admission, material) + if err != nil { + t.Fatalf("buildPostHandshakeAttestationProgressFromMaterial returned error: %v", err) + } + if !input.RuntimeEvidenceCollected { + t.Fatal("runtime evidence should be marked collected from runtime-reported material") + } + if got, want := input.AttestationSourceKind, launcherbackend.AttestationSourceKindTrustedRuntime; got != want { + t.Fatalf("attestation source kind = %q, want %q", got, want) + } +} + +func TestBuildPostHandshakeAttestationProgressRejectsRuntimeReportedEvidenceDigestMismatch(t *testing.T) { + spec, admission, receipt := runtimeAttestationReceiptFixtureForValidation(t) + secureSession := mustRuntimeSecureSessionMaterialForTests(t, spec, receipt) + summary, launchContextDigest, err := validateSecureSessionAndBuildSummary(receipt, secureSession) + if err != nil { + t.Fatalf("validateSecureSessionAndBuildSummary returned error: %v", err) + } + if err := recordValidatedSecureSession(&receipt, summary, launchContextDigest); err != nil { + t.Fatalf("recordValidatedSecureSession returned error: %v", err) + } + material := &launcherbackend.RuntimePostHandshakeMaterial{ + Attestation: &launcherbackend.PostHandshakeRuntimeAttestationInput{ + RunID: receipt.RunID, + IsolateID: receipt.IsolateID, + SessionID: receipt.SessionID, + SessionNonce: receipt.SessionNonce, + LaunchContextDigest: receipt.LaunchContextDigest, + HandshakeTranscriptHash: receipt.HandshakeTranscriptHash, + IsolateSessionKeyIDValue: receipt.IsolateSessionKeyIDValue, + RuntimeImageDescriptorDigest: receipt.RuntimeImageDescriptorDigest, + RuntimeImageBootProfile: receipt.RuntimeImageBootProfile, + RuntimeEvidenceCollected: true, + AttestationSourceKind: launcherbackend.AttestationSourceKindTrustedRuntime, + MeasurementProfile: admission.AttestationMeasurementProfile, + EvidenceClaimsDigest: "sha256:" + strings.Repeat("f", 64), + }, + } + _, err = buildPostHandshakeAttestationProgressFromMaterial(receipt, admission, material) + if err == nil { + t.Fatal("buildPostHandshakeAttestationProgressFromMaterial expected digest mismatch error") + } + if !strings.Contains(err.Error(), "runtime-reported evidence_claims_digest must bind to admitted runtime identity") { + t.Fatalf("error = %q, want admitted runtime identity binding failure", err.Error()) + } +} + +func TestBuildPostHandshakeAttestationProgressRejectsIncompleteRuntimeEvidenceBinding(t *testing.T) { + spec, admission, receipt := runtimeAttestationReceiptFixtureForValidation(t) + secureSession := mustRuntimeSecureSessionMaterialForTests(t, spec, receipt) + summary, launchContextDigest, err := validateSecureSessionAndBuildSummary(receipt, secureSession) + if err != nil { + t.Fatalf("validateSecureSessionAndBuildSummary returned error: %v", err) + } + if err := recordValidatedSecureSession(&receipt, summary, launchContextDigest); err != nil { + t.Fatalf("recordValidatedSecureSession returned error: %v", err) + } + material := &launcherbackend.RuntimePostHandshakeMaterial{ + Attestation: &launcherbackend.PostHandshakeRuntimeAttestationInput{ + RuntimeEvidenceCollected: true, + AttestationSourceKind: launcherbackend.AttestationSourceKindTrustedRuntime, + MeasurementProfile: admission.AttestationMeasurementProfile, + EvidenceClaimsDigest: admission.AttestationExpectedMeasurementDigests[0], + }, + } + _, err = buildPostHandshakeAttestationProgressFromMaterial(receipt, admission, material) + if err == nil { + t.Fatal("buildPostHandshakeAttestationProgressFromMaterial expected incomplete binding error") + } + if !strings.Contains(err.Error(), "runtime-reported attestation input must include full validated session binding") { + t.Fatalf("error = %q, want incomplete binding failure", err.Error()) + } +} + +func TestBuildPostHandshakeAttestationProgressIgnoresRuntimeVerificationVerdicts(t *testing.T) { + spec, admission, receipt := runtimeAttestationReceiptFixtureForValidation(t) + secureSession := mustRuntimeSecureSessionMaterialForTests(t, spec, receipt) + summary, launchContextDigest, err := validateSecureSessionAndBuildSummary(receipt, secureSession) + if err != nil { + t.Fatalf("validateSecureSessionAndBuildSummary returned error: %v", err) + } + if err := recordValidatedSecureSession(&receipt, summary, launchContextDigest); err != nil { + t.Fatalf("recordValidatedSecureSession returned error: %v", err) + } + material := &launcherbackend.RuntimePostHandshakeMaterial{ + Attestation: &launcherbackend.PostHandshakeRuntimeAttestationInput{ + RunID: receipt.RunID, + IsolateID: receipt.IsolateID, + SessionID: receipt.SessionID, + SessionNonce: receipt.SessionNonce, + LaunchContextDigest: receipt.LaunchContextDigest, + HandshakeTranscriptHash: receipt.HandshakeTranscriptHash, + IsolateSessionKeyIDValue: receipt.IsolateSessionKeyIDValue, + RuntimeImageDescriptorDigest: receipt.RuntimeImageDescriptorDigest, + RuntimeImageBootProfile: receipt.RuntimeImageBootProfile, + RuntimeEvidenceCollected: true, + AttestationSourceKind: launcherbackend.AttestationSourceKindTrustedRuntime, + MeasurementProfile: admission.AttestationMeasurementProfile, + EvidenceClaimsDigest: admission.AttestationExpectedMeasurementDigests[0], + VerificationResult: launcherbackend.AttestationVerificationResultValid, + ReplayVerdict: launcherbackend.AttestationReplayVerdictOriginal, + }, + } + input, err := buildPostHandshakeAttestationProgressFromMaterial(receipt, admission, material) + if err != nil { + t.Fatalf("buildPostHandshakeAttestationProgressFromMaterial returned error: %v", err) + } + if input.VerificationResult != launcherbackend.AttestationVerificationResultUnknown { + t.Fatalf("verification result = %q, want unknown", input.VerificationResult) + } + if input.ReplayVerdict != launcherbackend.AttestationReplayVerdictUnknown { + t.Fatalf("replay verdict = %q, want unknown", input.ReplayVerdict) + } +} + +func runtimeAttestationReceiptFixtureForValidation(t *testing.T) (launcherbackend.BackendLaunchSpec, launcherbackend.RuntimeAdmissionRecord, launcherbackend.BackendLaunchReceipt) { + t.Helper() + spec := validSpecForTests() + admission, err := launcherbackend.NewRuntimeAdmissionRecord(spec.Image) + if err != nil { + t.Fatalf("NewRuntimeAdmissionRecord returned error: %v", err) + } + binding := mustDeriveRuntimeSessionBinding(t, spec, admission.DescriptorDigest, "isolate-1", "session-1", strings.Repeat("a", 32)) + receipt := launcherbackend.BackendLaunchReceipt{ + RunID: spec.RunID, + StageID: spec.StageID, + RoleInstanceID: spec.RoleInstanceID, + BackendKind: launcherbackend.BackendKindMicroVM, + IsolationAssuranceLevel: launcherbackend.IsolationAssuranceIsolated, + TransportKind: launcherbackend.TransportKindVSock, + RuntimeImageDescriptorDigest: admission.DescriptorDigest, + RuntimeImageBootProfile: admission.BootContractVersion, + BootComponentDigestByName: cloneMap(admission.ComponentDigests), + AuthorityStateDigest: admission.AuthorityStateDigest, + } + populateRuntimeSessionBinding(&receipt, binding) + return spec, admission, receipt +} + +func assertValidatedReceiptStillTOFU(t *testing.T, receipt launcherbackend.BackendLaunchReceipt) { + t.Helper() + if got, want := receipt.ProvisioningPosture, launcherbackend.ProvisioningPostureTOFU; got != want { + t.Fatalf("provisioning posture = %q, want %q", got, want) + } + if receipt.SessionSecurity == nil { + t.Fatal("session_security missing after secure-session validation") + } + if !receipt.SessionSecurity.MutuallyAuthenticated || !receipt.SessionSecurity.Encrypted || !receipt.SessionSecurity.ProofOfPossessionVerified { + t.Fatal("session_security did not record validated secure-session posture") + } + if got, want := receipt.AttestationVerificationResult, launcherbackend.AttestationVerificationResultUnknown; got != want { + t.Fatalf("attestation verification result = %q, want %q", got, want) + } +} + +func assertPostHandshakeInputUsesReceiptBinding(t *testing.T, receipt launcherbackend.BackendLaunchReceipt, postHandshakeInput *launcherbackend.PostHandshakeRuntimeAttestationInput) { + t.Helper() + if postHandshakeInput == nil { + t.Fatal("post-handshake attestation input missing") + } + if got, want := postHandshakeInput.LaunchContextDigest, receipt.LaunchContextDigest; got != want { + t.Fatalf("post-handshake launch context digest = %q, want %q", got, want) + } + if got, want := postHandshakeInput.VerificationResult, launcherbackend.AttestationVerificationResultUnknown; got != want { + t.Fatalf("post-handshake verification result = %q, want %q", got, want) + } + if postHandshakeInput.VerificationTimestamp != "" { + t.Fatalf("post-handshake verification timestamp = %q, want empty", postHandshakeInput.VerificationTimestamp) + } +} + +func TestBuildPostHandshakeAttestationProgressFailsWithoutValidatedSessionBinding(t *testing.T) { + spec := validSpecForTests() + admission, err := launcherbackend.NewRuntimeAdmissionRecord(spec.Image) + if err != nil { + t.Fatalf("NewRuntimeAdmissionRecord returned error: %v", err) + } + receipt := launcherbackend.BackendLaunchReceipt{ + RunID: spec.RunID, + StageID: spec.StageID, + RoleInstanceID: spec.RoleInstanceID, + TransportKind: launcherbackend.TransportKindVSock, + RuntimeImageDescriptorDigest: admission.DescriptorDigest, + RuntimeImageBootProfile: admission.BootContractVersion, + BootComponentDigestByName: cloneMap(admission.ComponentDigests), + } + + _, err = buildPostHandshakeAttestationProgress(receipt, admission) + if err == nil { + t.Fatal("buildPostHandshakeAttestationProgress expected error") + } + if !strings.Contains(err.Error(), "session binding is required before attestation") { + t.Fatalf("error = %q, want missing secure-session binding", err.Error()) + } +} + +func mustRuntimeSecureSessionMaterialForTests(t *testing.T, spec launcherbackend.BackendLaunchSpec, receipt launcherbackend.BackendLaunchReceipt) *launcherbackend.RuntimeSecureSessionMaterial { + t.Helper() + handshakeTuple, _, err := secureSessionHandshakeTuple(spec, receipt) + if err != nil { + t.Fatalf("secureSessionHandshakeTuple returned error: %v", err) + } + return &launcherbackend.RuntimeSecureSessionMaterial{ + LaunchContext: handshakeTuple.launchContext, + HostHello: handshakeTuple.host, + IsolateHello: handshakeTuple.isolate, + SessionReady: handshakeTuple.ready, + } +} diff --git a/internal/launcherdaemon/runtime_post_handshake_material_provider.go b/internal/launcherdaemon/runtime_post_handshake_material_provider.go new file mode 100644 index 00000000..a0fc3cf2 --- /dev/null +++ b/internal/launcherdaemon/runtime_post_handshake_material_provider.go @@ -0,0 +1,74 @@ +package launcherdaemon + +import ( + "fmt" + "strings" + + "github.com/runecode-ai/runecode/internal/launcherbackend" +) + +func defaultRuntimePostHandshakeMaterialProvider(spec launcherbackend.BackendLaunchSpec, receipt launcherbackend.BackendLaunchReceipt) (*launcherbackend.RuntimePostHandshakeMaterial, error) { + handshakeTuple, _, err := secureSessionHandshakeTuple(spec, receipt) + if err != nil { + return nil, err + } + measurementProfile, evidenceClaimsDigest, err := runtimeAttestationMeasurementInputs(receipt) + if err != nil { + return nil, err + } + return &launcherbackend.RuntimePostHandshakeMaterial{ + SecureSession: &launcherbackend.RuntimeSecureSessionMaterial{ + LaunchContext: handshakeTuple.launchContext, + HostHello: handshakeTuple.host, + IsolateHello: handshakeTuple.isolate, + SessionReady: handshakeTuple.ready, + }, + Attestation: &launcherbackend.PostHandshakeRuntimeAttestationInput{ + RunID: receipt.RunID, + IsolateID: receipt.IsolateID, + SessionID: receipt.SessionID, + SessionNonce: receipt.SessionNonce, + RuntimeEvidenceCollected: true, + LaunchContextDigest: handshakeTuple.launchContext.LaunchContextDigest, + HandshakeTranscriptHash: handshakeTuple.ready.HandshakeTranscriptHash, + IsolateSessionKeyIDValue: handshakeTuple.ready.IsolateKeyIDValue, + RuntimeImageDescriptorDigest: receipt.RuntimeImageDescriptorDigest, + RuntimeImageBootProfile: receipt.RuntimeImageBootProfile, + RuntimeImageVerifierRef: receipt.RuntimeImageVerifierRef, + AuthorityStateDigest: receipt.AuthorityStateDigest, + BootComponentDigestByName: cloneMap(receipt.BootComponentDigestByName), + BootComponentDigests: componentDigestValues(receipt.BootComponentDigestByName), + AttestationSourceKind: launcherbackend.AttestationSourceKindTrustedRuntime, + MeasurementProfile: measurementProfile, + FreshnessMaterial: []string{"session_nonce"}, + FreshnessBindingClaims: []string{"session_nonce", "handshake_transcript_hash", "launch_context_digest"}, + EvidenceClaimsDigest: evidenceClaimsDigest, + }, + }, nil +} + +func runtimeAttestationMeasurementInputs(receipt launcherbackend.BackendLaunchReceipt) (string, string, error) { + measurementProfile, err := measurementProfileForLaunchReceipt(receipt) + if err != nil { + return "", "", err + } + expectedMeasurementDigests, err := launcherbackend.DeriveExpectedMeasurementDigests(measurementProfile, receipt.RuntimeImageBootProfile, receipt.BootComponentDigestByName) + if err != nil { + return "", "", err + } + if len(expectedMeasurementDigests) == 0 { + return "", "", fmt.Errorf("expected measurement digests are required for post-handshake attestation material") + } + return measurementProfile, expectedMeasurementDigests[0], nil +} + +func measurementProfileForLaunchReceipt(receipt launcherbackend.BackendLaunchReceipt) (string, error) { + switch strings.TrimSpace(receipt.RuntimeImageBootProfile) { + case launcherbackend.BootProfileMicroVMLinuxKernelInitrdV1: + return launcherbackend.MeasurementProfileMicroVMBootV1, nil + case launcherbackend.BootProfileContainerOCIImageV1: + return launcherbackend.MeasurementProfileContainerImageV1, nil + default: + return "", fmt.Errorf("unsupported runtime image boot profile %q for post-handshake attestation material", receipt.RuntimeImageBootProfile) + } +} diff --git a/internal/launcherdaemon/service.go b/internal/launcherdaemon/service.go index 775ed256..252779b0 100644 --- a/internal/launcherdaemon/service.go +++ b/internal/launcherdaemon/service.go @@ -29,13 +29,23 @@ func resolveControllers(cfg Config) (Controller, Controller) { if cfg.Controller != nil { return cfg.Controller, cfg.Controller } + runtimeMaterialProvider := cfg.RuntimePostHandshakeMaterialProvider + if runtimeMaterialProvider == nil { + runtimeMaterialProvider = defaultRuntimePostHandshakeMaterialProvider + } microVMController := cfg.MicroVMController if microVMController == nil { - microVMController = NewQEMUController(QEMUControllerConfig{WorkRoot: cfg.WorkRoot}) + microVMController = NewQEMUController(QEMUControllerConfig{ + WorkRoot: cfg.WorkRoot, + RuntimePostHandshakeMaterialProvider: runtimeMaterialProvider, + }) } containerController := cfg.ContainerController if containerController == nil { - containerController = NewContainerController(ContainerControllerConfig{WorkRoot: cfg.WorkRoot}) + containerController = NewContainerController(ContainerControllerConfig{ + WorkRoot: cfg.WorkRoot, + RuntimePostHandshakeMaterialProvider: runtimeMaterialProvider, + }) } return microVMController, containerController } diff --git a/internal/launcherdaemon/service_default_controllers_linux_test.go b/internal/launcherdaemon/service_default_controllers_linux_test.go new file mode 100644 index 00000000..85659f68 --- /dev/null +++ b/internal/launcherdaemon/service_default_controllers_linux_test.go @@ -0,0 +1,52 @@ +//go:build linux + +package launcherdaemon + +import ( + "testing" + + "github.com/runecode-ai/runecode/internal/launcherbackend" +) + +func TestResolveControllersDefaultWiresRuntimePostHandshakeProviderForBothBackends(t *testing.T) { + micro, container := resolveControllers(Config{WorkRoot: t.TempDir()}) + + microController, ok := micro.(*qemuController) + if !ok { + t.Fatalf("microvm controller type = %T, want *qemuController", micro) + } + if microController.cfg.RuntimePostHandshakeMaterialProvider == nil { + t.Fatal("microvm default runtime post-handshake material provider must be configured") + } + + containerController, ok := container.(*containerController) + if !ok { + t.Fatalf("container controller type = %T, want *containerController", container) + } + if containerController.runtimePostHandshakeMaterialProvider == nil { + t.Fatal("container default runtime post-handshake material provider must be configured") + } +} + +func TestResolveControllersUsesConfiguredRuntimePostHandshakeProviderForBothBackends(t *testing.T) { + called := 0 + provider := func(launcherbackend.BackendLaunchSpec, launcherbackend.BackendLaunchReceipt) (*launcherbackend.RuntimePostHandshakeMaterial, error) { + called += 1 + return &launcherbackend.RuntimePostHandshakeMaterial{}, nil + } + micro, container := resolveControllers(Config{WorkRoot: t.TempDir(), RuntimePostHandshakeMaterialProvider: provider}) + + microController := micro.(*qemuController) + if _, err := microController.cfg.RuntimePostHandshakeMaterialProvider(launcherbackend.BackendLaunchSpec{}, launcherbackend.BackendLaunchReceipt{}); err != nil { + t.Fatalf("microvm runtime material provider returned error: %v", err) + } + + containerController := container.(*containerController) + if _, err := containerController.runtimePostHandshakeMaterialProvider(launcherbackend.BackendLaunchSpec{}, launcherbackend.BackendLaunchReceipt{}); err != nil { + t.Fatalf("container runtime material provider returned error: %v", err) + } + + if got, want := called, 2; got != want { + t.Fatalf("provider call count = %d, want %d", got, want) + } +} diff --git a/internal/launcherdaemon/service_launch_denial_test.go b/internal/launcherdaemon/service_launch_denial_test.go index f0fecfc1..f80f1818 100644 --- a/internal/launcherdaemon/service_launch_denial_test.go +++ b/internal/launcherdaemon/service_launch_denial_test.go @@ -24,10 +24,11 @@ func TestServiceLaunchFailureRecordsDeniedRuntimeFacts(t *testing.T) { if _, err := svc.Launch(context.Background(), validContainerSpecForTests()); err == nil { t.Fatal("Launch expected error") } - if len(reporter.facts) != 1 { - t.Fatalf("runtime facts count = %d, want 1 denied-launch record", len(reporter.facts)) + facts := reporter.factsSnapshot() + if len(facts) != 1 { + t.Fatalf("runtime facts count = %d, want 1 denied-launch record", len(facts)) } - assertDeniedLaunchReceipt(t, reporter.facts[0].LaunchReceipt, validContainerSpecForTests()) + assertDeniedLaunchReceipt(t, facts[0].LaunchReceipt, validContainerSpecForTests()) } func TestServiceLaunchFailurePreservesBackendErrorWhenDeniedFactsReportingFails(t *testing.T) { diff --git a/internal/launcherdaemon/service_test.go b/internal/launcherdaemon/service_test.go index d4040513..9e3c2e05 100644 --- a/internal/launcherdaemon/service_test.go +++ b/internal/launcherdaemon/service_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "runtime" + "sync" "testing" "time" @@ -90,6 +91,7 @@ func (f *fakeController) Shutdown(context.Context) error { } type fakeReporter struct { + mu sync.RWMutex facts []launcherbackend.RuntimeFactsSnapshot lifecycle []launcherbackend.RuntimeLifecycleState factsErr error @@ -430,10 +432,10 @@ func TestServiceLaunchConsumesRuntimeUpdates(t *testing.T) { t.Fatalf("Launch returned error: %v", err) } deadline := time.Now().Add(2 * time.Second) - for len(reporter.facts) == 0 && time.Now().Before(deadline) { + for reporter.factsCount() == 0 && time.Now().Before(deadline) { time.Sleep(10 * time.Millisecond) } - if len(reporter.facts) == 0 { + if reporter.factsCount() == 0 { t.Fatal("expected runtime facts update") } } diff --git a/internal/launcherdaemon/service_test_support_test.go b/internal/launcherdaemon/service_test_support_test.go index ca6ee514..5f4e0aa1 100644 --- a/internal/launcherdaemon/service_test_support_test.go +++ b/internal/launcherdaemon/service_test_support_test.go @@ -7,6 +7,8 @@ import ( ) func (f *fakeReporter) RecordRuntimeFacts(_ string, facts launcherbackend.RuntimeFactsSnapshot) error { + f.mu.Lock() + defer f.mu.Unlock() if f.factsErr != nil { return f.factsErr } @@ -15,6 +17,8 @@ func (f *fakeReporter) RecordRuntimeFacts(_ string, facts launcherbackend.Runtim } func (f *fakeReporter) RecordRuntimeLifecycleState(_ string, lifecycle launcherbackend.RuntimeLifecycleState) error { + f.mu.Lock() + defer f.mu.Unlock() if f.stateErr != nil { return f.stateErr } @@ -22,6 +26,20 @@ func (f *fakeReporter) RecordRuntimeLifecycleState(_ string, lifecycle launcherb return nil } +func (f *fakeReporter) factsCount() int { + f.mu.RLock() + defer f.mu.RUnlock() + return len(f.facts) +} + +func (f *fakeReporter) factsSnapshot() []launcherbackend.RuntimeFactsSnapshot { + f.mu.RLock() + defer f.mu.RUnlock() + out := make([]launcherbackend.RuntimeFactsSnapshot, len(f.facts)) + copy(out, f.facts) + return out +} + type scriptedController struct{} func (scriptedController) Launch(context.Context, launcherbackend.BackendLaunchSpec) (<-chan RuntimeUpdate, error) { diff --git a/internal/launcherdaemon/service_types.go b/internal/launcherdaemon/service_types.go index 0d15e381..26158264 100644 --- a/internal/launcherdaemon/service_types.go +++ b/internal/launcherdaemon/service_types.go @@ -72,8 +72,11 @@ type Config struct { // Optional backend-specific controller overrides. MicroVMController Controller ContainerController Controller - Reporter RuntimeReporter - WorkRoot string + // RuntimePostHandshakeMaterialProvider supplies trusted post-handshake + // session and attestation material for default backend controllers. + RuntimePostHandshakeMaterialProvider func(launcherbackend.BackendLaunchSpec, launcherbackend.BackendLaunchReceipt) (*launcherbackend.RuntimePostHandshakeMaterial, error) + Reporter RuntimeReporter + WorkRoot string } type Service struct { diff --git a/internal/launcherdaemon/vertical_slice_linux_test.go b/internal/launcherdaemon/vertical_slice_linux_test.go index 3eae6919..b1702a11 100644 --- a/internal/launcherdaemon/vertical_slice_linux_test.go +++ b/internal/launcherdaemon/vertical_slice_linux_test.go @@ -32,7 +32,7 @@ func TestQEMUVerticalSliceHelloWorld(t *testing.T) { if err != nil { t.Fatalf("NewService returned error: %v", err) } - svc, err := New(Config{Controller: NewQEMUController(QEMUControllerConfig{WorkRoot: workRoot, QEMUBinary: qemuBinary}), Reporter: brokerSvc}) + svc, err := New(Config{Controller: NewQEMUController(QEMUControllerConfig{WorkRoot: workRoot, QEMUBinary: qemuBinary, RuntimePostHandshakeMaterialProvider: runtimePostHandshakeMaterialProviderForTests}), Reporter: brokerSvc}) if err != nil { t.Fatalf("New returned error: %v", err) } @@ -94,6 +94,14 @@ if [ "$1" = "--version" ]; then echo "QEMU emulator version fixture-vertical-slice-1.0" exit 0 fi +for arg in "$@"; do + case "$arg" in + *RUNE_POST_HANDSHAKE_MATERIAL_LINE=*) + value=${arg#*RUNE_POST_HANDSHAKE_MATERIAL_LINE=} + printf '%s\n' "$value" + ;; + esac +done printf '%s\n' "` + helloWorldToken + `" exit 0 ` @@ -278,3 +286,39 @@ func waitForCompletedTerminalReport(t *testing.T, brokerSvc *brokerapi.Service, } t.Fatal("timed out waiting for terminal report") } + +func runtimePostHandshakeMaterialProviderForTests(spec launcherbackend.BackendLaunchSpec, receipt launcherbackend.BackendLaunchReceipt) (*launcherbackend.RuntimePostHandshakeMaterial, error) { + handshakeTuple, _, err := secureSessionHandshakeTuple(spec, receipt) + if err != nil { + return nil, err + } + expectedMeasurementDigests, err := launcherbackend.DeriveExpectedMeasurementDigests(launcherbackend.MeasurementProfileMicroVMBootV1, receipt.RuntimeImageBootProfile, receipt.BootComponentDigestByName) + if err != nil { + return nil, err + } + return &launcherbackend.RuntimePostHandshakeMaterial{ + SecureSession: &launcherbackend.RuntimeSecureSessionMaterial{ + LaunchContext: handshakeTuple.launchContext, + HostHello: handshakeTuple.host, + IsolateHello: handshakeTuple.isolate, + SessionReady: handshakeTuple.ready, + }, + Attestation: &launcherbackend.PostHandshakeRuntimeAttestationInput{ + RunID: receipt.RunID, + IsolateID: receipt.IsolateID, + SessionID: receipt.SessionID, + SessionNonce: receipt.SessionNonce, + LaunchContextDigest: handshakeTuple.launchContext.LaunchContextDigest, + HandshakeTranscriptHash: handshakeTuple.ready.HandshakeTranscriptHash, + IsolateSessionKeyIDValue: handshakeTuple.ready.IsolateKeyIDValue, + RuntimeImageDescriptorDigest: receipt.RuntimeImageDescriptorDigest, + RuntimeImageBootProfile: receipt.RuntimeImageBootProfile, + RuntimeEvidenceCollected: true, + AttestationSourceKind: launcherbackend.AttestationSourceKindTrustedRuntime, + MeasurementProfile: launcherbackend.MeasurementProfileMicroVMBootV1, + FreshnessMaterial: []string{"session_nonce"}, + FreshnessBindingClaims: []string{"session_nonce", "handshake_transcript_hash", "launch_context_digest"}, + EvidenceClaimsDigest: expectedMeasurementDigests[0], + }, + }, nil +} diff --git a/internal/launcherperf/harness.go b/internal/launcherperf/harness.go new file mode 100644 index 00000000..fdf4eba9 --- /dev/null +++ b/internal/launcherperf/harness.go @@ -0,0 +1,178 @@ +package launcherperf + +import ( + "fmt" + "strings" + "time" + + "github.com/runecode-ai/runecode/internal/launcherbackend" + "github.com/runecode-ai/runecode/internal/perfcontracts" +) + +const CheckSchemaVersion = "runecode.performance.check.v1" + +type HarnessConfig struct{} + +func Run(_ HarnessConfig) (perfcontracts.CheckOutput, error) { + measurements := make([]perfcontracts.MeasurementRecord, 0, 8) + + microVMCold, microVMWarm, err := simulateBackendStartup(launcherbackend.BackendKindMicroVM) + if err != nil { + return perfcontracts.CheckOutput{}, err + } + measurements = append(measurements, + perfcontracts.MeasurementRecord{MetricID: "metric.launcher.microvm.cold_start.wall_ms", Value: microVMCold, Unit: "ms"}, + perfcontracts.MeasurementRecord{MetricID: "metric.launcher.microvm.warm_start.wall_ms", Value: microVMWarm, Unit: "ms"}, + ) + + containerCold, containerWarm, err := simulateBackendStartup(launcherbackend.BackendKindContainer) + if err != nil { + return perfcontracts.CheckOutput{}, err + } + measurements = append(measurements, + perfcontracts.MeasurementRecord{MetricID: "metric.launcher.container.cold_start.wall_ms", Value: containerCold, Unit: "ms"}, + perfcontracts.MeasurementRecord{MetricID: "metric.launcher.container.warm_start.wall_ms", Value: containerWarm, Unit: "ms"}, + ) + + attestCold, attestWarm, err := simulateAttestationPath() + if err != nil { + return perfcontracts.CheckOutput{}, err + } + measurements = append(measurements, + perfcontracts.MeasurementRecord{MetricID: "metric.attestation.cold.verify.wall_ms", Value: attestCold, Unit: "ms"}, + perfcontracts.MeasurementRecord{MetricID: "metric.attestation.warm.verify.wall_ms", Value: attestWarm, Unit: "ms"}, + ) + + return perfcontracts.CheckOutput{SchemaVersion: CheckSchemaVersion, Measurements: measurements}, nil +} + +func simulateBackendStartup(backend string) (float64, float64, error) { + image, err := deterministicRuntimeImage(backend) + if err != nil { + return 0, 0, err + } + admissionStart := time.Now() + record, err := launcherbackend.NewRuntimeAdmissionRecord(image) + if err != nil { + return 0, 0, err + } + if err := record.Validate(); err != nil { + return 0, 0, err + } + cold := float64(time.Since(admissionStart).Milliseconds()) + + warmStart := time.Now() + if err := record.Validate(); err != nil { + return 0, 0, err + } + warm := float64(time.Since(warmStart).Milliseconds()) + return cold, warm, nil +} + +func simulateAttestationPath() (float64, float64, error) { + image, err := deterministicRuntimeImage(launcherbackend.BackendKindMicroVM) + if err != nil { + return 0, 0, err + } + receipt := launcherbackend.BackendLaunchReceipt{ + RunID: "run-attestation", + BackendKind: launcherbackend.BackendKindMicroVM, + IsolationAssuranceLevel: launcherbackend.IsolationAssuranceIsolated, + ProvisioningPosture: launcherbackend.ProvisioningPostureAttested, + RuntimeImageDescriptorDigest: image.DescriptorDigest, + RuntimeImageBootProfile: image.BootContractVersion, + BootComponentDigestByName: image.ComponentDigests, + AttestationEvidenceSourceKind: launcherbackend.AttestationSourceKindTrustedRuntime, + AttestationMeasurementProfile: image.Attestation.MeasurementProfile, + AttestationEvidenceDigest: "sha256:" + repeatHex('a'), + AttestationVerificationResult: launcherbackend.AttestationVerificationResultValid, + AttestationReplayVerdict: launcherbackend.AttestationReplayVerdictOriginal, + AttestationVerificationDigest: "sha256:" + repeatHex('b'), + AttestationVerificationReasonCodes: []string{}, + } + + coldStart := time.Now() + posture, reasons := launcherbackend.DeriveAttestationPosture(receipt) + if posture != launcherbackend.AttestationPostureValid || len(reasons) > 0 { + return 0, 0, fmt.Errorf("unexpected cold attestation posture %q reasons=%v", posture, reasons) + } + cold := float64(time.Since(coldStart).Milliseconds()) + + warmStart := time.Now() + posture, reasons = launcherbackend.DeriveAttestationPosture(receipt) + if posture != launcherbackend.AttestationPostureValid || len(reasons) > 0 { + return 0, 0, fmt.Errorf("unexpected warm attestation posture %q reasons=%v", posture, reasons) + } + warm := float64(time.Since(warmStart).Milliseconds()) + return cold, warm, nil +} + +func deterministicRuntimeImage(backend string) (launcherbackend.RuntimeImageDescriptor, error) { + boot, measurementProfile, accel, componentDigests := runtimeImageBackendParams(backend) + image := runtimeImageDescriptorBase(backend, boot, accel, componentDigests, measurementProfile) + if digests, err := launcherbackend.DeriveExpectedMeasurementDigests(measurementProfile, boot, componentDigests); err == nil { + image.Attestation.ExpectedMeasurementDigests = digests + } + digest, err := image.ExpectedDescriptorDigest() + if err != nil { + return launcherbackend.RuntimeImageDescriptor{}, err + } + image.DescriptorDigest = digest + image.Signing.PayloadDigest = digest + return image, nil +} + +func runtimeImageBackendParams(backend string) (string, string, string, map[string]string) { + componentDigests := map[string]string{} + boot := launcherbackend.BootProfileMicroVMLinuxKernelInitrdV1 + measurementProfile := launcherbackend.MeasurementProfileMicroVMBootV1 + accel := launcherbackend.AccelerationKindKVM + if backend == launcherbackend.BackendKindContainer { + boot = launcherbackend.BootProfileContainerOCIImageV1 + measurementProfile = launcherbackend.MeasurementProfileContainerImageV1 + accel = launcherbackend.AccelerationKindNotApplicable + componentDigests["image"] = "sha256:" + repeatHex('3') + return boot, measurementProfile, accel, componentDigests + } + componentDigests["kernel"] = "sha256:" + repeatHex('1') + componentDigests["initrd"] = "sha256:" + repeatHex('2') + return boot, measurementProfile, accel, componentDigests +} + +func runtimeImageDescriptorBase(backend, boot, accel string, componentDigests map[string]string, measurementProfile string) launcherbackend.RuntimeImageDescriptor { + return launcherbackend.RuntimeImageDescriptor{ + BackendKind: backend, + BootContractVersion: boot, + PlatformCompatibility: launcherbackend.RuntimeImagePlatformCompat{ + OS: "linux", + Architecture: "amd64", + AccelerationKind: accel, + }, + ComponentDigests: componentDigests, + Signing: &launcherbackend.RuntimeImageSigningHooks{ + PayloadSchemaID: launcherbackend.RuntimeImageSignedPayloadSchemaID, + PayloadSchemaVersion: launcherbackend.RuntimeImageSignedPayloadSchemaVersion, + PayloadDigest: "sha256:" + repeatHex('4'), + SignerRef: "verifier:runtime-image:v1", + SignatureDigest: "sha256:" + repeatHex('5'), + VerifierSetRef: "sha256:" + repeatHex('6'), + Toolchain: runtimeToolchainSigningHooks(), + }, + Attestation: &launcherbackend.RuntimeImageAttestationHook{MeasurementProfile: measurementProfile}, + } +} + +func runtimeToolchainSigningHooks() *launcherbackend.RuntimeToolchainSigningHooks { + return &launcherbackend.RuntimeToolchainSigningHooks{ + DescriptorSchemaID: launcherbackend.RuntimeToolchainDescriptorSchemaID, + DescriptorSchemaVersion: launcherbackend.RuntimeToolchainDescriptorSchemaVersion, + DescriptorDigest: "sha256:" + repeatHex('7'), + SignerRef: "verifier:runtime-toolchain:v1", + SignatureDigest: "sha256:" + repeatHex('8'), + VerifierSetRef: "sha256:" + repeatHex('9'), + } +} + +func repeatHex(ch rune) string { + return strings.Repeat(string(ch), 64) +} diff --git a/internal/launcherperf/harness_test.go b/internal/launcherperf/harness_test.go new file mode 100644 index 00000000..a4fbdcb7 --- /dev/null +++ b/internal/launcherperf/harness_test.go @@ -0,0 +1,39 @@ +package launcherperf + +import ( + "testing" + + "github.com/runecode-ai/runecode/internal/perfcontracts" +) + +func TestRunProducesPhase4LauncherAndAttestationMetrics(t *testing.T) { + out, err := Run(HarnessConfig{}) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + if out.SchemaVersion != CheckSchemaVersion { + t.Fatalf("schema_version = %q, want %q", out.SchemaVersion, CheckSchemaVersion) + } + required := map[string]string{ + "metric.launcher.microvm.cold_start.wall_ms": "ms", + "metric.launcher.microvm.warm_start.wall_ms": "ms", + "metric.launcher.container.cold_start.wall_ms": "ms", + "metric.launcher.container.warm_start.wall_ms": "ms", + "metric.attestation.cold.verify.wall_ms": "ms", + "metric.attestation.warm.verify.wall_ms": "ms", + } + for metricID, unit := range required { + if !hasMetric(out.Measurements, metricID, unit) { + t.Fatalf("missing metric %s (%s)", metricID, unit) + } + } +} + +func hasMetric(measurements []perfcontracts.MeasurementRecord, metricID, unit string) bool { + for _, m := range measurements { + if m.MetricID == metricID && m.Unit == unit { + return true + } + } + return false +} diff --git a/internal/perfcontracts/evaluate.go b/internal/perfcontracts/evaluate.go new file mode 100644 index 00000000..e9e71f09 --- /dev/null +++ b/internal/perfcontracts/evaluate.go @@ -0,0 +1,158 @@ +package perfcontracts + +import "fmt" + +type Violation struct { + MetricID string + Reason string +} + +func Evaluate(check CheckOutput, contracts []ContractFile, baselineByMetric map[string]BaselineFile) []Violation { + measurementByMetric := map[string]MeasurementRecord{} + for _, measurement := range check.Measurements { + measurementByMetric[measurement.MetricID] = measurement + } + var violations []Violation + for _, contract := range contracts { + for _, metric := range contract.Metrics { + measurement, ok := measurementByMetric[metric.MetricID] + if !ok { + violations = append(violations, Violation{MetricID: metric.MetricID, Reason: "measurement missing from check output"}) + continue + } + if measurement.Unit != metric.Unit { + violations = append(violations, Violation{MetricID: metric.MetricID, Reason: fmt.Sprintf("unit mismatch: got %s want %s", measurement.Unit, metric.Unit)}) + continue + } + violations = append(violations, evaluateMetric(metric, measurement.Value, baselineByMetric[metric.MetricID])...) + } + } + return violations +} + +func evaluateMetric(metric MetricContract, measured float64, baseline BaselineFile) []Violation { + switch metric.ComparisonMethod { + case "exact_match": + return evaluateExactMetric(metric, measured) + case "absolute_ceiling", "max_ceiling", "p95_ceiling", "window_average", "window_max": + return evaluateAbsoluteBudgetMetric(metric, measured) + case "median_regression_with_noise_floor": + return evaluateRegressionMetric(metric, measured, baseline) + case "median_plus_regression", "p95_ceiling_plus_regression": + return evaluateHybridMetric(metric, measured, baseline) + case "": + return evaluateMetricByBudgetClass(metric, measured, baseline) + default: + return []Violation{{MetricID: metric.MetricID, Reason: fmt.Sprintf("unsupported comparison method %q", metric.ComparisonMethod)}} + } +} + +func evaluateMetricByBudgetClass(metric MetricContract, measured float64, baseline BaselineFile) []Violation { + switch metric.BudgetClass { + case "exact": + return evaluateExactMetric(metric, measured) + case "absolute-budget": + return evaluateAbsoluteBudgetMetric(metric, measured) + case "regression-budget": + return evaluateRegressionMetric(metric, measured, baseline) + case "hybrid-budget": + return evaluateHybridMetric(metric, measured, baseline) + default: + return []Violation{{MetricID: metric.MetricID, Reason: "unsupported budget class"}} + } +} + +func evaluateExactMetric(metric MetricContract, measured float64) []Violation { + if metric.Threshold.ExactValue == nil { + return []Violation{{MetricID: metric.MetricID, Reason: "exact threshold missing exact_value"}} + } + if measured == *metric.Threshold.ExactValue { + return nil + } + return []Violation{{MetricID: metric.MetricID, Reason: fmt.Sprintf("exact mismatch: got %.4f want %.4f", measured, *metric.Threshold.ExactValue)}} +} + +func evaluateAbsoluteBudgetMetric(metric MetricContract, measured float64) []Violation { + if metric.Threshold.MaxValue == nil { + return []Violation{{MetricID: metric.MetricID, Reason: "absolute-budget threshold missing max_value"}} + } + if measured <= *metric.Threshold.MaxValue { + return nil + } + return []Violation{{MetricID: metric.MetricID, Reason: fmt.Sprintf("value %.4f exceeds max %.4f", measured, *metric.Threshold.MaxValue)}} +} + +func evaluateRegressionMetric(metric MetricContract, measured float64, baseline BaselineFile) []Violation { + violates, details := regressionViolation(metric, measured, baseline) + if !violates { + return nil + } + return []Violation{{MetricID: metric.MetricID, Reason: details}} +} + +func evaluateHybridMetric(metric MetricContract, measured float64, baseline BaselineFile) []Violation { + violations := evaluateAbsoluteBudgetMetric(metric, measured) + if violates, details := regressionViolation(metric, measured, baseline); violates { + violations = append(violations, Violation{MetricID: metric.MetricID, Reason: "hybrid " + details}) + } + return violations +} + +func regressionViolation(metric MetricContract, measured float64, baseline BaselineFile) (bool, string) { + if metric.Threshold.MaxRegressionPercent == nil { + return true, "regression threshold missing max_regression_percent" + } + base, ok := baselineValue(baseline) + if !ok || base == 0 { + return true, "regression threshold missing usable baseline" + } + delta := measured - base + if delta <= 0 { + return false, "" + } + if delta < metric.NoiseFloor { + return false, "" + } + percent := (delta / base) * 100.0 + if percent <= *metric.Threshold.MaxRegressionPercent { + return false, "" + } + allowed := base * (1 + (*metric.Threshold.MaxRegressionPercent / 100.0)) + return true, fmt.Sprintf( + "regression threshold exceeded: value %.4f%s baseline %.4f%s allowed <= %.4f%s (+%.2f%%, max +%.2f%%, noise floor %.4f%s)", + measured, metric.Unit, + base, metric.Unit, + allowed, metric.Unit, + percent, *metric.Threshold.MaxRegressionPercent, + metric.NoiseFloor, metric.Unit, + ) +} + +func baselineValue(file BaselineFile) (float64, bool) { + if file.BaselineValue != nil { + return *file.BaselineValue, true + } + if file.Summary.Median != nil { + return *file.Summary.Median, true + } + if len(file.Samples) == 0 { + return 0, false + } + return median(file.Samples), true +} + +func median(values []float64) float64 { + cp := append([]float64{}, values...) + for i := 0; i < len(cp); i++ { + for j := i + 1; j < len(cp); j++ { + if cp[j] < cp[i] { + cp[i], cp[j] = cp[j], cp[i] + } + } + } + m := len(cp) / 2 + if len(cp)%2 == 0 { + return (cp[m-1] + cp[m]) / 2 + } + return cp[m] +} diff --git a/internal/perfcontracts/evaluate_test.go b/internal/perfcontracts/evaluate_test.go new file mode 100644 index 00000000..2f1437d8 --- /dev/null +++ b/internal/perfcontracts/evaluate_test.go @@ -0,0 +1,185 @@ +package perfcontracts + +import ( + "strings" + "testing" +) + +func TestEvaluateHonorsComparisonMethodContracts(t *testing.T) { + max500 := 500.0 + max200 := 200.0 + reg15 := 15.0 + exact2 := 2.0 + + for _, tc := range comparisonMethodContractTests(max500, max200, reg15, exact2) { + t.Run(tc.name, func(t *testing.T) { + assertEvaluationResult(t, tc) + }) + } +} + +type comparisonMethodTestCase struct { + name string + metric MetricContract + measurement MeasurementRecord + baseline BaselineFile + wantViolation bool + wantReasonLike string +} + +func comparisonMethodContractTests(max500, max200, reg15, exact2 float64) []comparisonMethodTestCase { + tests := append([]comparisonMethodTestCase{}, absoluteComparisonMethodTests(max500, max200, exact2)...) + tests = append(tests, regressionComparisonMethodTests(max500, reg15)...) + return tests +} + +func absoluteComparisonMethodTests(max500, max200, exact2 float64) []comparisonMethodTestCase { + tests := append([]comparisonMethodTestCase{}, absoluteCeilingComparisonTests(max500, max200)...) + return append(tests, absoluteComparisonOverrideTests(max200, exact2)...) +} + +func absoluteCeilingComparisonTests(max500, max200 float64) []comparisonMethodTestCase { + tests := append([]comparisonMethodTestCase{}, exactAndAbsoluteComparisonTests(max500)...) + return append(tests, percentileAndWindowComparisonTests(max200)...) +} + +func exactAndAbsoluteComparisonTests(max500 float64) []comparisonMethodTestCase { + return []comparisonMethodTestCase{ + { + name: "exact_match passes on exact value", + metric: MetricContract{MetricID: "m.exact.pass", Unit: "count", BudgetClass: "exact", ComparisonMethod: "exact_match", Threshold: MetricThreshold{ExactValue: floatPtr(2)}}, + measurement: MeasurementRecord{MetricID: "m.exact.pass", Unit: "count", Value: 2}, + wantViolation: false, + }, + { + name: "exact_match fails on near-equal value", + metric: MetricContract{MetricID: "m.exact.near.fail", Unit: "count", BudgetClass: "exact", ComparisonMethod: "exact_match", Threshold: MetricThreshold{ExactValue: floatPtr(2)}}, + measurement: MeasurementRecord{MetricID: "m.exact.near.fail", Unit: "count", Value: 2.0000000001}, + wantViolation: true, + wantReasonLike: "exact mismatch", + }, + { + name: "absolute_ceiling fails above max", + metric: MetricContract{MetricID: "m.abs.fail", Unit: "ms", BudgetClass: "absolute-budget", ComparisonMethod: "absolute_ceiling", Threshold: MetricThreshold{MaxValue: &max500}}, + measurement: MeasurementRecord{MetricID: "m.abs.fail", Unit: "ms", Value: 501}, + wantViolation: true, + wantReasonLike: "exceeds max", + }, + { + name: "max_ceiling passes under max", + metric: MetricContract{MetricID: "m.max.pass", Unit: "ms", BudgetClass: "absolute-budget", ComparisonMethod: "max_ceiling", Threshold: MetricThreshold{MaxValue: &max500}}, + measurement: MeasurementRecord{MetricID: "m.max.pass", Unit: "ms", Value: 499}, + wantViolation: false, + }, + } +} + +func percentileAndWindowComparisonTests(max200 float64) []comparisonMethodTestCase { + return []comparisonMethodTestCase{ + { + name: "p95_ceiling fails above max", + metric: MetricContract{MetricID: "m.p95.fail", Unit: "ms", BudgetClass: "absolute-budget", ComparisonMethod: "p95_ceiling", Threshold: MetricThreshold{MaxValue: &max200}}, + measurement: MeasurementRecord{MetricID: "m.p95.fail", Unit: "ms", Value: 220}, + wantViolation: true, + wantReasonLike: "exceeds max", + }, + { + name: "window_average fails above max", + metric: MetricContract{MetricID: "m.win.avg.fail", Unit: "percent", BudgetClass: "absolute-budget", ComparisonMethod: "window_average", Threshold: MetricThreshold{MaxValue: &max200}}, + measurement: MeasurementRecord{MetricID: "m.win.avg.fail", Unit: "percent", Value: 220}, + wantViolation: true, + wantReasonLike: "exceeds max", + }, + { + name: "window_max fails above max", + metric: MetricContract{MetricID: "m.win.max.fail", Unit: "percent", BudgetClass: "absolute-budget", ComparisonMethod: "window_max", Threshold: MetricThreshold{MaxValue: &max200}}, + measurement: MeasurementRecord{MetricID: "m.win.max.fail", Unit: "percent", Value: 220}, + wantViolation: true, + wantReasonLike: "exceeds max", + }, + } +} + +func absoluteComparisonOverrideTests(max200, exact2 float64) []comparisonMethodTestCase { + return []comparisonMethodTestCase{ + { + name: "comparison method takes precedence over budget class", + metric: MetricContract{MetricID: "m.method.overrides.budget", Unit: "ms", BudgetClass: "exact", ComparisonMethod: "absolute_ceiling", Threshold: MetricThreshold{ExactValue: &exact2, MaxValue: &max200}}, + measurement: MeasurementRecord{MetricID: "m.method.overrides.budget", Unit: "ms", Value: 220}, + wantViolation: true, + wantReasonLike: "exceeds max", + }, + } +} + +func regressionComparisonMethodTests(max500, reg15 float64) []comparisonMethodTestCase { + return []comparisonMethodTestCase{ + { + name: "median_regression_with_noise_floor ignores low-noise deltas", + metric: MetricContract{MetricID: "m.reg.noise.pass", Unit: "ns/op", BudgetClass: "regression-budget", ComparisonMethod: "median_regression_with_noise_floor", Threshold: MetricThreshold{MaxRegressionPercent: ®15}, NoiseFloor: 10}, + measurement: MeasurementRecord{MetricID: "m.reg.noise.pass", Unit: "ns/op", Value: 105}, + baseline: medianBaseline(100), + wantViolation: false, + }, + { + name: "median_regression_with_noise_floor reports measured and allowed values", + metric: MetricContract{MetricID: "m.reg.fail", Unit: "ms", BudgetClass: "regression-budget", ComparisonMethod: "median_regression_with_noise_floor", Threshold: MetricThreshold{MaxRegressionPercent: ®15}, NoiseFloor: 10}, + measurement: MeasurementRecord{MetricID: "m.reg.fail", Unit: "ms", Value: 140}, + baseline: medianBaseline(100), + wantViolation: true, + wantReasonLike: "value 140.0000ms baseline 100.0000ms allowed <= 115.0000ms", + }, + { + name: "median_plus_regression checks absolute max", + metric: MetricContract{MetricID: "m.med.plus.abs.fail", Unit: "ms", BudgetClass: "hybrid-budget", ComparisonMethod: "median_plus_regression", Threshold: MetricThreshold{MaxValue: &max500, MaxRegressionPercent: ®15}, NoiseFloor: 10}, + measurement: MeasurementRecord{MetricID: "m.med.plus.abs.fail", Unit: "ms", Value: 550}, + baseline: BaselineFile{BaselineValue: floatPtr(1000)}, + wantViolation: true, + wantReasonLike: "exceeds max", + }, + { + name: "p95_ceiling_plus_regression checks regression baseline", + metric: MetricContract{MetricID: "m.p95.plus.reg.fail", Unit: "ms", BudgetClass: "hybrid-budget", ComparisonMethod: "p95_ceiling_plus_regression", Threshold: MetricThreshold{MaxValue: &max500, MaxRegressionPercent: ®15}, NoiseFloor: 5}, + measurement: MeasurementRecord{MetricID: "m.p95.plus.reg.fail", Unit: "ms", Value: 130}, + baseline: medianBaseline(100), + wantViolation: true, + wantReasonLike: "hybrid regression threshold exceeded", + }, + } +} + +func assertEvaluationResult(t *testing.T, tc comparisonMethodTestCase) { + t.Helper() + violations := Evaluate( + CheckOutput{Measurements: []MeasurementRecord{tc.measurement}}, + []ContractFile{{ContractID: "c", Metrics: []MetricContract{tc.metric}}}, + map[string]BaselineFile{tc.metric.MetricID: tc.baseline}, + ) + + if tc.wantViolation && len(violations) == 0 { + t.Fatalf("violations = %#v, want at least one violation", violations) + } + if !tc.wantViolation && len(violations) != 0 { + t.Fatalf("violations = %#v, want no violations", violations) + } + if tc.wantReasonLike != "" && !containsViolationReason(violations, tc.metric.MetricID, tc.wantReasonLike) { + t.Fatalf("violations = %#v, want reason containing %q", violations, tc.wantReasonLike) + } +} + +func containsViolationReason(violations []Violation, metricID, wantReasonLike string) bool { + for _, v := range violations { + if v.MetricID == metricID && strings.Contains(v.Reason, wantReasonLike) { + return true + } + } + return false +} + +func medianBaseline(v float64) BaselineFile { + return BaselineFile{Summary: struct { + Median *float64 `json:"median,omitempty"` + }{Median: floatPtr(v)}} +} + +func floatPtr(v float64) *float64 { return &v } diff --git a/internal/perfcontracts/load.go b/internal/perfcontracts/load.go new file mode 100644 index 00000000..0b8aa39a --- /dev/null +++ b/internal/perfcontracts/load.go @@ -0,0 +1,63 @@ +package perfcontracts + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +func LoadManifest(root string) (Manifest, error) { + path := filepath.Join(root, "manifest.json") + var manifest Manifest + if err := loadJSON(path, &manifest); err != nil { + return Manifest{}, err + } + return manifest, nil +} + +func LoadFixtureInventory(root, relPath string) (FixtureInventory, error) { + path := filepath.Join(root, filepath.FromSlash(relPath)) + var inventory FixtureInventory + if err := loadJSON(path, &inventory); err != nil { + return FixtureInventory{}, err + } + return inventory, nil +} + +func LoadContract(root, relPath string) (ContractFile, error) { + path := filepath.Join(root, filepath.FromSlash(relPath)) + var contract ContractFile + if err := loadJSON(path, &contract); err != nil { + return ContractFile{}, err + } + return contract, nil +} + +func LoadBaseline(root, relPath string) (BaselineFile, error) { + path := filepath.Join(root, filepath.FromSlash(relPath)) + var baseline BaselineFile + if err := loadJSON(path, &baseline); err != nil { + return BaselineFile{}, err + } + return baseline, nil +} + +func LoadCheckOutput(path string) (CheckOutput, error) { + var out CheckOutput + if err := loadJSON(path, &out); err != nil { + return CheckOutput{}, err + } + return out, nil +} + +func loadJSON(path string, dst any) error { + raw, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + if err := json.Unmarshal(raw, dst); err != nil { + return fmt.Errorf("decode %s: %w", path, err) + } + return nil +} diff --git a/internal/perfcontracts/types.go b/internal/perfcontracts/types.go new file mode 100644 index 00000000..0fee31a8 --- /dev/null +++ b/internal/perfcontracts/types.go @@ -0,0 +1,123 @@ +package perfcontracts + +type Manifest struct { + SchemaVersion string `json:"schema_version"` + ManifestVersion string `json:"manifest_version"` + ChangeRef string `json:"change_ref"` + FixtureInventoryRef string `json:"fixture_inventory_ref"` + MeasurementProfiles []string `json:"measurement_profiles,omitempty"` + Contracts []ManifestContract `json:"contracts"` + Baselines []ManifestBaseline `json:"baselines,omitempty"` + Taxonomy MetricTaxonomy `json:"metric_taxonomy"` + LaneAuthorities []string `json:"lane_authorities"` + ActivationStates []string `json:"activation_states"` + Deferrals []ManifestDeferral `json:"deferrals,omitempty"` +} + +type ManifestContract struct { + Surface string `json:"surface"` + Path string `json:"path"` +} + +type ManifestBaseline struct { + MetricID string `json:"metric_id"` + Path string `json:"path"` +} + +type ManifestDeferral struct { + ChangeRef string `json:"change_ref"` + Reason string `json:"reason"` +} + +type MetricTaxonomy struct { + BudgetClasses []string `json:"budget_classes"` +} + +type FixtureInventory struct { + SchemaVersion string `json:"schema_version"` + Fixtures []FixtureRecord `json:"fixtures"` +} + +type FixtureRecord struct { + FixtureID string `json:"fixture_id"` + Surface string `json:"surface"` + RuntimeRegime string `json:"runtime_regime"` + Status string `json:"status"` + Notes string `json:"notes,omitempty"` +} + +type ContractFile struct { + SchemaVersion string `json:"schema_version"` + ContractID string `json:"contract_id"` + Surface string `json:"surface"` + Metrics []MetricContract `json:"metrics"` +} + +type MetricContract struct { + MetricID string `json:"metric_id"` + Subsystem string `json:"subsystem"` + RuntimeRegime string `json:"runtime_regime"` + FixtureID string `json:"fixture_id"` + MeasurementKind string `json:"measurement_kind"` + Unit string `json:"unit"` + AuthoritativeEnv string `json:"authoritative_environment"` + MeasurementProfile string `json:"measurement_profile,omitempty"` + SamplingPolicy SamplingPolicy `json:"sampling_policy"` + BudgetClass string `json:"budget_class"` + Threshold MetricThreshold `json:"threshold"` + LaneAuthority string `json:"lane_authority"` + ActivationState string `json:"activation_state"` + BaselineSource string `json:"baseline_source,omitempty"` + BaselineRef string `json:"baseline_ref,omitempty"` + ComparisonMethod string `json:"comparison_method"` + NoiseFloor float64 `json:"practical_noise_floor,omitempty"` + ThresholdOrigin string `json:"threshold_origin"` + TimingBoundary TimingBoundary `json:"timing_boundary"` + Notes string `json:"notes,omitempty"` +} + +type SamplingPolicy struct { + Trials int `json:"trials,omitempty"` + RepeatedSamples int `json:"repeated_samples,omitempty"` + WarmupMillis int `json:"warmup_millis,omitempty"` + ObservationWindowMs int `json:"observation_window_millis,omitempty"` + ObservationWindows int `json:"observation_windows,omitempty"` + P95Authoritative bool `json:"p95_authoritative,omitempty"` + MedianMaxAuthoritative bool `json:"median_max_authoritative,omitempty"` +} + +type MetricThreshold struct { + ExactValue *float64 `json:"exact_value,omitempty"` + MaxValue *float64 `json:"max_value,omitempty"` + MaxRegressionPercent *float64 `json:"max_regression_percent,omitempty"` +} + +type TimingBoundary struct { + StartEvent string `json:"start_event"` + EndEvent string `json:"end_event"` + ClockSource string `json:"clock_source"` + EvidenceSource string `json:"evidence_source"` + IncludedPhases []string `json:"included_phases"` +} + +type CheckOutput struct { + SchemaVersion string `json:"schema_version"` + Measurements []MeasurementRecord `json:"measurements"` +} + +type MeasurementRecord struct { + MetricID string `json:"metric_id"` + Value float64 `json:"value"` + Unit string `json:"unit"` +} + +type BaselineFile struct { + SchemaVersion string `json:"schema_version"` + MetricID string `json:"metric_id"` + Unit string `json:"unit"` + BaselineValue *float64 `json:"baseline_value,omitempty"` + Samples []float64 `json:"samples,omitempty"` + Summary struct { + Median *float64 `json:"median,omitempty"` + } `json:"summary,omitempty"` +} diff --git a/internal/perfcontracts/validate.go b/internal/perfcontracts/validate.go new file mode 100644 index 00000000..2afe7696 --- /dev/null +++ b/internal/perfcontracts/validate.go @@ -0,0 +1,254 @@ +package perfcontracts + +import ( + "fmt" + "strings" +) + +var allowedBudgetClasses = map[string]struct{}{ + "exact": {}, + "absolute-budget": {}, + "regression-budget": {}, + "hybrid-budget": {}, +} + +var allowedLaneAuthorities = map[string]struct{}{ + "required_shared_linux": {}, + "required_tight_linux": {}, + "informational_until_stable": {}, + "contract_pending_dependency": {}, + "extended": {}, +} + +var allowedActivationStates = map[string]struct{}{ + "defined": {}, + "informational": {}, + "required": {}, + "contract_pending_dependency": {}, +} + +var allowedMeasurementProfiles = map[string]struct{}{ + "linux_shared_ci": {}, + "linux_pi_reference": {}, + "linux_scaled_reference": {}, +} + +var allowedThresholdOrigins = map[string]struct{}{ + "product_budget": {}, + "investigation_baseline": {}, + "first_calibration": {}, + "temporary_guardrail": {}, +} + +func Validate(manifest Manifest, inventory FixtureInventory, contracts []ContractFile) error { + return ValidateWithBaselines(manifest, inventory, contracts, nil) +} + +func ValidateWithBaselines(manifest Manifest, inventory FixtureInventory, contracts []ContractFile, baselinesByMetric map[string]BaselineFile) error { + if err := validateManifestAndInventory(manifest, inventory); err != nil { + return err + } + reviewedMeasurementProfiles := measurementProfileSet(manifest.MeasurementProfiles) + baselineRefsByMetric, err := baselineRefSet(manifest.Baselines) + if err != nil { + return err + } + fixtures, err := fixtureSet(inventory) + if err != nil { + return err + } + return validateContracts(contracts, fixtures, reviewedMeasurementProfiles, baselinesByMetric, baselineRefsByMetric) +} + +func validateManifestAndInventory(manifest Manifest, inventory FixtureInventory) error { + if strings.TrimSpace(manifest.SchemaVersion) == "" { + return fmt.Errorf("manifest schema_version is required") + } + if strings.TrimSpace(inventory.SchemaVersion) == "" { + return fmt.Errorf("fixture inventory schema_version is required") + } + for _, profile := range manifest.MeasurementProfiles { + normalized := strings.TrimSpace(profile) + if normalized == "" { + return fmt.Errorf("measurement_profiles entries must be non-empty") + } + if _, ok := allowedMeasurementProfiles[normalized]; !ok { + return fmt.Errorf("measurement_profile %q unsupported", normalized) + } + } + return nil +} + +func fixtureSet(inventory FixtureInventory) (map[string]struct{}, error) { + fixtures := map[string]struct{}{} + for _, fixture := range inventory.Fixtures { + if strings.TrimSpace(fixture.FixtureID) == "" { + return nil, fmt.Errorf("fixture_id is required") + } + fixtures[fixture.FixtureID] = struct{}{} + } + return fixtures, nil +} + +func measurementProfileSet(profiles []string) map[string]struct{} { + set := map[string]struct{}{} + for _, profile := range profiles { + set[strings.TrimSpace(profile)] = struct{}{} + } + return set +} + +func baselineRefSet(entries []ManifestBaseline) (map[string]string, error) { + refs := map[string]string{} + for _, entry := range entries { + if existing, ok := refs[entry.MetricID]; ok { + return nil, fmt.Errorf("manifest baseline metric_id %q duplicated with paths %q and %q", entry.MetricID, existing, entry.Path) + } + refs[entry.MetricID] = entry.Path + } + return refs, nil +} + +func validateContracts(contracts []ContractFile, fixtures map[string]struct{}, reviewedMeasurementProfiles map[string]struct{}, baselinesByMetric map[string]BaselineFile, baselineRefsByMetric map[string]string) error { + for _, contract := range contracts { + if err := validateContract(contract, fixtures, reviewedMeasurementProfiles, baselinesByMetric, baselineRefsByMetric); err != nil { + return err + } + } + return nil +} + +func validateContract(contract ContractFile, fixtures map[string]struct{}, reviewedMeasurementProfiles map[string]struct{}, baselinesByMetric map[string]BaselineFile, baselineRefsByMetric map[string]string) error { + if strings.TrimSpace(contract.SchemaVersion) == "" { + return fmt.Errorf("contract %s missing schema_version", contract.ContractID) + } + for _, metric := range contract.Metrics { + if err := validateMetric(metric, fixtures, reviewedMeasurementProfiles, baselinesByMetric, baselineRefsByMetric); err != nil { + return fmt.Errorf("contract %s metric %s invalid: %w", contract.ContractID, metric.MetricID, err) + } + } + return nil +} + +func validateMetric(metric MetricContract, fixtures map[string]struct{}, reviewedMeasurementProfiles map[string]struct{}, baselinesByMetric map[string]BaselineFile, baselineRefsByMetric map[string]string) error { + checks := []func(MetricContract, map[string]struct{}, map[string]struct{}, map[string]BaselineFile, map[string]string) error{ + validateMetricIdentity, + validateMetricEnums, + validateMetricFixture, + validateMetricThresholdOrigin, + validateMetricTimingBoundary, + validateMetricBaseline, + } + for _, check := range checks { + if err := check(metric, fixtures, reviewedMeasurementProfiles, baselinesByMetric, baselineRefsByMetric); err != nil { + return err + } + } + return nil +} + +func validateMetricIdentity(metric MetricContract, _ map[string]struct{}, _ map[string]struct{}, _ map[string]BaselineFile, _ map[string]string) error { + if strings.TrimSpace(metric.MetricID) == "" { + return fmt.Errorf("metric_id is required") + } + return nil +} + +func validateMetricEnums(metric MetricContract, _ map[string]struct{}, reviewedMeasurementProfiles map[string]struct{}, _ map[string]BaselineFile, _ map[string]string) error { + if _, ok := allowedBudgetClasses[metric.BudgetClass]; !ok { + return fmt.Errorf("budget_class %q unsupported", metric.BudgetClass) + } + if _, ok := allowedLaneAuthorities[metric.LaneAuthority]; !ok { + return fmt.Errorf("lane_authority %q unsupported", metric.LaneAuthority) + } + if _, ok := allowedActivationStates[metric.ActivationState]; !ok { + return fmt.Errorf("activation_state %q unsupported", metric.ActivationState) + } + if profile := strings.TrimSpace(metric.MeasurementProfile); profile != "" { + if _, ok := allowedMeasurementProfiles[profile]; !ok { + return fmt.Errorf("measurement_profile %q unsupported", metric.MeasurementProfile) + } + if _, ok := reviewedMeasurementProfiles[profile]; !ok { + return fmt.Errorf("measurement_profile %q missing from manifest measurement_profiles", metric.MeasurementProfile) + } + } + return nil +} + +func validateMetricFixture(metric MetricContract, fixtures map[string]struct{}, _ map[string]struct{}, _ map[string]BaselineFile, _ map[string]string) error { + if _, ok := fixtures[metric.FixtureID]; !ok { + return fmt.Errorf("fixture_id %q missing from inventory", metric.FixtureID) + } + return nil +} + +func validateMetricThresholdOrigin(metric MetricContract, _ map[string]struct{}, _ map[string]struct{}, _ map[string]BaselineFile, _ map[string]string) error { + if strings.TrimSpace(metric.ThresholdOrigin) == "" { + return fmt.Errorf("threshold_origin is required") + } + if _, ok := allowedThresholdOrigins[metric.ThresholdOrigin]; !ok { + return fmt.Errorf("threshold_origin %q unsupported", metric.ThresholdOrigin) + } + return nil +} + +func validateMetricTimingBoundary(metric MetricContract, _ map[string]struct{}, _ map[string]struct{}, _ map[string]BaselineFile, _ map[string]string) error { + boundary := metric.TimingBoundary + if strings.TrimSpace(boundary.StartEvent) == "" || strings.TrimSpace(boundary.EndEvent) == "" { + return fmt.Errorf("timing_boundary start_event/end_event are required") + } + if strings.TrimSpace(boundary.ClockSource) == "" || strings.TrimSpace(boundary.EvidenceSource) == "" { + return fmt.Errorf("timing_boundary clock_source/evidence_source are required") + } + if len(boundary.IncludedPhases) == 0 { + return fmt.Errorf("timing_boundary included_phases is required") + } + return nil +} + +func validateMetricBaseline(metric MetricContract, _ map[string]struct{}, _ map[string]struct{}, baselinesByMetric map[string]BaselineFile, baselineRefsByMetric map[string]string) error { + if !requiresBaselineValidation(metric) { + return nil + } + if strings.TrimSpace(metric.BaselineRef) == "" { + return fmt.Errorf("baseline_ref is required for %s", metric.BudgetClass) + } + if err := validateBaselineRefProvenance(metric, baselineRefsByMetric); err != nil { + return err + } + if baselinesByMetric == nil { + return nil + } + baseline, ok := baselinesByMetric[metric.MetricID] + if !ok { + return fmt.Errorf("baseline for metric_id %q missing from manifest baselines", metric.MetricID) + } + if strings.TrimSpace(baseline.MetricID) != metric.MetricID { + return fmt.Errorf("baseline metric_id %q does not match contract metric_id %q", baseline.MetricID, metric.MetricID) + } + if strings.TrimSpace(baseline.Unit) != metric.Unit { + return fmt.Errorf("baseline unit %q does not match contract unit %q", baseline.Unit, metric.Unit) + } + if _, ok := baselineValue(baseline); !ok { + return fmt.Errorf("baseline for metric_id %q has no usable baseline value", metric.MetricID) + } + return nil +} + +func requiresBaselineValidation(metric MetricContract) bool { + if metric.BudgetClass != "regression-budget" && metric.BudgetClass != "hybrid-budget" { + return false + } + return metric.ActivationState == "required" +} + +func validateBaselineRefProvenance(metric MetricContract, baselineRefsByMetric map[string]string) error { + authoritativeRef, ok := baselineRefsByMetric[metric.MetricID] + if !ok { + return fmt.Errorf("baseline_ref for metric_id %q missing from manifest baselines", metric.MetricID) + } + if strings.TrimSpace(authoritativeRef) != metric.BaselineRef { + return fmt.Errorf("baseline_ref %q does not match manifest baseline path %q for metric_id %q", metric.BaselineRef, authoritativeRef, metric.MetricID) + } + return nil +} diff --git a/internal/perfcontracts/validate_test.go b/internal/perfcontracts/validate_test.go new file mode 100644 index 00000000..e757e22a --- /dev/null +++ b/internal/perfcontracts/validate_test.go @@ -0,0 +1,292 @@ +package perfcontracts + +import "testing" + +func TestValidateAcceptsReviewedMetricContract(t *testing.T) { + manifest := Manifest{SchemaVersion: "runecode.performance.manifest.v1"} + inventory := FixtureInventory{SchemaVersion: "runecode.performance.fixtures.v1", Fixtures: []FixtureRecord{{FixtureID: "tui.empty.v1"}}} + contracts := []ContractFile{{ + SchemaVersion: "runecode.performance.contract.v1", + ContractID: "performance.tui.v1", + Metrics: []MetricContract{{ + MetricID: "metric.tui.attach.latency.p95", + FixtureID: "tui.empty.v1", + BudgetClass: "absolute-budget", + LaneAuthority: "required_shared_linux", + ActivationState: "required", + ThresholdOrigin: "product_budget", + TimingBoundary: TimingBoundary{ + StartEvent: "spawn", + EndEvent: "ready", + ClockSource: "monotonic", + EvidenceSource: "events", + IncludedPhases: []string{"launch"}, + }, + }}, + }} + if err := Validate(manifest, inventory, contracts); err != nil { + t.Fatalf("Validate returned error: %v", err) + } +} + +func TestValidateAcceptsReviewedMeasurementProfiles(t *testing.T) { + manifest := Manifest{SchemaVersion: "runecode.performance.manifest.v1", MeasurementProfiles: []string{"linux_shared_ci", "linux_pi_reference", "linux_scaled_reference"}} + inventory := FixtureInventory{SchemaVersion: "runecode.performance.fixtures.v1", Fixtures: []FixtureRecord{{FixtureID: "tui.empty.v1"}}} + contracts := []ContractFile{{ + SchemaVersion: "runecode.performance.contract.v1", + ContractID: "performance.tui.v1", + Metrics: []MetricContract{{ + MetricID: "metric.tui.attach.latency.p95", + FixtureID: "tui.empty.v1", + BudgetClass: "absolute-budget", + LaneAuthority: "required_shared_linux", + ActivationState: "required", + MeasurementProfile: "linux_pi_reference", + ThresholdOrigin: "product_budget", + TimingBoundary: TimingBoundary{ + StartEvent: "spawn", + EndEvent: "ready", + ClockSource: "monotonic", + EvidenceSource: "events", + IncludedPhases: []string{"launch"}, + }, + }}, + }} + if err := Validate(manifest, inventory, contracts); err != nil { + t.Fatalf("Validate returned error: %v", err) + } +} + +func TestValidateRejectsUnreviewedMeasurementProfile(t *testing.T) { + manifest := Manifest{SchemaVersion: "runecode.performance.manifest.v1", MeasurementProfiles: []string{"linux_shared_ci"}} + inventory := FixtureInventory{SchemaVersion: "runecode.performance.fixtures.v1", Fixtures: []FixtureRecord{{FixtureID: "tui.empty.v1"}}} + contracts := []ContractFile{{ + SchemaVersion: "runecode.performance.contract.v1", + ContractID: "performance.tui.v1", + Metrics: []MetricContract{{ + MetricID: "metric.tui.attach.latency.p95", + FixtureID: "tui.empty.v1", + BudgetClass: "absolute-budget", + LaneAuthority: "required_shared_linux", + ActivationState: "required", + MeasurementProfile: "linux_pi_reference", + ThresholdOrigin: "product_budget", + TimingBoundary: TimingBoundary{ + StartEvent: "spawn", + EndEvent: "ready", + ClockSource: "monotonic", + EvidenceSource: "events", + IncludedPhases: []string{"launch"}, + }, + }}, + }} + if err := Validate(manifest, inventory, contracts); err == nil { + t.Fatal("Validate error = nil, want manifest measurement_profiles failure") + } +} + +func TestValidateRejectsUnsupportedMeasurementProfile(t *testing.T) { + manifest := Manifest{SchemaVersion: "runecode.performance.manifest.v1"} + inventory := FixtureInventory{SchemaVersion: "runecode.performance.fixtures.v1", Fixtures: []FixtureRecord{{FixtureID: "tui.empty.v1"}}} + contracts := []ContractFile{{ + SchemaVersion: "runecode.performance.contract.v1", + ContractID: "performance.tui.v1", + Metrics: []MetricContract{{ + MetricID: "metric.tui.attach.latency.p95", + FixtureID: "tui.empty.v1", + BudgetClass: "absolute-budget", + LaneAuthority: "required_shared_linux", + ActivationState: "required", + MeasurementProfile: "linux_unknown_reference", + ThresholdOrigin: "product_budget", + TimingBoundary: TimingBoundary{StartEvent: "spawn", EndEvent: "ready", ClockSource: "monotonic", EvidenceSource: "events", IncludedPhases: []string{"launch"}}, + }}, + }} + if err := Validate(manifest, inventory, contracts); err == nil { + t.Fatal("Validate error = nil, want measurement_profile failure") + } +} + +func TestValidateRejectsMissingFixtureReference(t *testing.T) { + manifest := Manifest{SchemaVersion: "runecode.performance.manifest.v1"} + inventory := FixtureInventory{SchemaVersion: "runecode.performance.fixtures.v1", Fixtures: []FixtureRecord{{FixtureID: "tui.empty.v1"}}} + contracts := []ContractFile{{ + SchemaVersion: "runecode.performance.contract.v1", + ContractID: "performance.tui.v1", + Metrics: []MetricContract{{ + MetricID: "metric.tui.attach.latency.p95", + FixtureID: "missing.v1", + BudgetClass: "absolute-budget", + LaneAuthority: "required_shared_linux", + ActivationState: "required", + ThresholdOrigin: "product_budget", + TimingBoundary: TimingBoundary{StartEvent: "spawn", EndEvent: "ready", ClockSource: "monotonic", EvidenceSource: "events", IncludedPhases: []string{"launch"}}, + }}, + }} + if err := Validate(manifest, inventory, contracts); err == nil { + t.Fatal("Validate error = nil, want missing fixture failure") + } +} + +func TestValidateRejectsUnsupportedThresholdOrigin(t *testing.T) { + manifest := Manifest{SchemaVersion: "runecode.performance.manifest.v1"} + inventory := FixtureInventory{SchemaVersion: "runecode.performance.fixtures.v1", Fixtures: []FixtureRecord{{FixtureID: "tui.empty.v1"}}} + contracts := []ContractFile{{ + SchemaVersion: "runecode.performance.contract.v1", + ContractID: "performance.tui.v1", + Metrics: []MetricContract{{ + MetricID: "metric.tui.attach.latency.p95", + FixtureID: "tui.empty.v1", + BudgetClass: "absolute-budget", + LaneAuthority: "required_shared_linux", + ActivationState: "required", + ThresholdOrigin: "unsupported_origin", + TimingBoundary: TimingBoundary{StartEvent: "spawn", EndEvent: "ready", ClockSource: "monotonic", EvidenceSource: "events", IncludedPhases: []string{"launch"}}, + }}, + }} + if err := Validate(manifest, inventory, contracts); err == nil { + t.Fatal("Validate error = nil, want threshold_origin failure") + } +} + +func TestValidateWithBaselinesRejectsRegressionMetricWithoutBaseline(t *testing.T) { + manifest := Manifest{SchemaVersion: "runecode.performance.manifest.v1"} + inventory := FixtureInventory{SchemaVersion: "runecode.performance.fixtures.v1", Fixtures: []FixtureRecord{{FixtureID: "tui.empty.v1"}}} + threshold := 15.0 + contracts := []ContractFile{{ + SchemaVersion: "runecode.performance.contract.v1", + ContractID: "performance.tui.v1", + Metrics: []MetricContract{{ + MetricID: "metric.tui.render.shell_view_empty.ns_op", + FixtureID: "tui.empty.v1", + Unit: "ns/op", + BudgetClass: "regression-budget", + BaselineRef: "baselines/metric.tui.render.shell_view_empty.ns_op.v1.json", + LaneAuthority: "required_shared_linux", + ActivationState: "required", + ThresholdOrigin: "first_calibration", + Threshold: MetricThreshold{MaxRegressionPercent: &threshold}, + TimingBoundary: TimingBoundary{StartEvent: "start", EndEvent: "end", ClockSource: "monotonic", EvidenceSource: "bench", IncludedPhases: []string{"render"}}, + }}, + }} + if err := ValidateWithBaselines(manifest, inventory, contracts, map[string]BaselineFile{}); err == nil { + t.Fatal("ValidateWithBaselines error = nil, want missing baseline failure") + } +} + +func TestValidateWithBaselinesAllowsNonRequiredRegressionMetricWithoutBaseline(t *testing.T) { + manifest := Manifest{SchemaVersion: "runecode.performance.manifest.v1"} + inventory := FixtureInventory{SchemaVersion: "runecode.performance.fixtures.v1", Fixtures: []FixtureRecord{{FixtureID: "tui.empty.v1"}}} + threshold := 15.0 + contracts := []ContractFile{{ + SchemaVersion: "runecode.performance.contract.v1", + ContractID: "performance.tui.v1", + Metrics: []MetricContract{{ + MetricID: "metric.tui.render.shell_view_empty.ns_op", + FixtureID: "tui.empty.v1", + Unit: "ns/op", + BudgetClass: "regression-budget", + LaneAuthority: "required_shared_linux", + ActivationState: "defined", + ThresholdOrigin: "first_calibration", + Threshold: MetricThreshold{MaxRegressionPercent: &threshold}, + TimingBoundary: TimingBoundary{StartEvent: "start", EndEvent: "end", ClockSource: "monotonic", EvidenceSource: "bench", IncludedPhases: []string{"render"}}, + }}, + }} + if err := ValidateWithBaselines(manifest, inventory, contracts, map[string]BaselineFile{}); err != nil { + t.Fatalf("ValidateWithBaselines returned error for non-required regression metric: %v", err) + } +} + +func TestValidateWithBaselinesRejectsMismatchedBaselineUnit(t *testing.T) { + manifest, inventory, contracts, baselines := mismatchedBaselineUnitFixture() + if err := ValidateWithBaselines(manifest, inventory, contracts, baselines); err == nil { + t.Fatal("ValidateWithBaselines error = nil, want baseline unit mismatch failure") + } +} + +func mismatchedBaselineUnitFixture() (Manifest, FixtureInventory, []ContractFile, map[string]BaselineFile) { + manifest := Manifest{ + SchemaVersion: "runecode.performance.manifest.v1", + Baselines: []ManifestBaseline{{ + MetricID: "metric.tui.render.shell_view_empty.ns_op", + Path: "baselines/metric.tui.render.shell_view_empty.ns_op.v1.json", + }}, + } + inventory := FixtureInventory{SchemaVersion: "runecode.performance.fixtures.v1", Fixtures: []FixtureRecord{{FixtureID: "tui.empty.v1"}}} + threshold := 15.0 + median := 1000.0 + contracts := []ContractFile{{ + SchemaVersion: "runecode.performance.contract.v1", + ContractID: "performance.tui.v1", + Metrics: []MetricContract{{ + MetricID: "metric.tui.render.shell_view_empty.ns_op", + FixtureID: "tui.empty.v1", + Unit: "ns/op", + BudgetClass: "regression-budget", + BaselineRef: "baselines/metric.tui.render.shell_view_empty.ns_op.v1.json", + LaneAuthority: "required_shared_linux", + ActivationState: "required", + ThresholdOrigin: "first_calibration", + Threshold: MetricThreshold{MaxRegressionPercent: &threshold}, + TimingBoundary: TimingBoundary{StartEvent: "start", EndEvent: "end", ClockSource: "monotonic", EvidenceSource: "bench", IncludedPhases: []string{"render"}}, + }}, + }} + baselines := map[string]BaselineFile{ + "metric.tui.render.shell_view_empty.ns_op": { + SchemaVersion: "runecode.performance.baseline.v1", + MetricID: "metric.tui.render.shell_view_empty.ns_op", + Unit: "ms", + Summary: struct { + Median *float64 `json:"median,omitempty"` + }{Median: &median}, + }, + } + return manifest, inventory, contracts, baselines +} + +func TestValidateWithBaselinesRejectsMismatchedBaselineRefProvenance(t *testing.T) { + manifest := Manifest{ + SchemaVersion: "runecode.performance.manifest.v1", + Baselines: []ManifestBaseline{{ + MetricID: "metric.tui.render.shell_view_empty.ns_op", + Path: "baselines/metric.tui.render.shell_view_empty.ns_op.v1.json", + }}, + } + inventory := FixtureInventory{SchemaVersion: "runecode.performance.fixtures.v1", Fixtures: []FixtureRecord{{FixtureID: "tui.empty.v1"}}} + threshold := 15.0 + contracts := []ContractFile{{ + SchemaVersion: "runecode.performance.contract.v1", + ContractID: "performance.tui.v1", + Metrics: []MetricContract{{ + MetricID: "metric.tui.render.shell_view_empty.ns_op", + FixtureID: "tui.empty.v1", + Unit: "ns/op", + BudgetClass: "regression-budget", + BaselineRef: "baselines/other-baseline.v1.json", + LaneAuthority: "required_shared_linux", + ActivationState: "required", + ThresholdOrigin: "first_calibration", + Threshold: MetricThreshold{MaxRegressionPercent: &threshold}, + TimingBoundary: TimingBoundary{StartEvent: "start", EndEvent: "end", ClockSource: "monotonic", EvidenceSource: "bench", IncludedPhases: []string{"render"}}, + }}, + }} + if err := ValidateWithBaselines(manifest, inventory, contracts, nil); err == nil { + t.Fatal("ValidateWithBaselines error = nil, want baseline_ref provenance mismatch failure") + } +} + +func TestValidateWithBaselinesRejectsDuplicateManifestBaselineMetricID(t *testing.T) { + manifest := Manifest{ + SchemaVersion: "runecode.performance.manifest.v1", + Baselines: []ManifestBaseline{ + {MetricID: "metric.sample", Path: "baselines/metric.sample.v1.json"}, + {MetricID: "metric.sample", Path: "baselines/metric.sample.v2.json"}, + }, + } + inventory := FixtureInventory{SchemaVersion: "runecode.performance.fixtures.v1", Fixtures: []FixtureRecord{{FixtureID: "fixture.sample"}}} + contracts := []ContractFile{{SchemaVersion: "runecode.performance.contract.v1", ContractID: "performance.sample.v1"}} + if err := ValidateWithBaselines(manifest, inventory, contracts, nil); err == nil { + t.Fatal("ValidateWithBaselines error = nil, want duplicate manifest baseline metric_id failure") + } +} diff --git a/internal/perffixtures/broker_store.go b/internal/perffixtures/broker_store.go new file mode 100644 index 00000000..842c088f --- /dev/null +++ b/internal/perffixtures/broker_store.go @@ -0,0 +1,82 @@ +package perffixtures + +import ( + "time" + + "github.com/runecode-ai/runecode/internal/artifacts" +) + +const ( + FixtureTUIEmptyV1 = "tui.empty.v1" + FixtureTUIWaitingV1 = "tui.waiting.v1" +) + +type BrokerStoreFixtureResult struct { + FixtureID string + SessionID string + TurnID string + RootDir string +} + +func BuildBrokerStoreFixture(rootDir string, fixtureID string) (BrokerStoreFixtureResult, error) { + switch fixtureID { + case FixtureTUIEmptyV1: + if _, err := artifacts.NewStore(rootDir); err != nil { + return BrokerStoreFixtureResult{}, err + } + return BrokerStoreFixtureResult{FixtureID: fixtureID, RootDir: rootDir}, nil + case FixtureTUIWaitingV1: + return buildBrokerStoreWaiting(rootDir) + default: + return BrokerStoreFixtureResult{}, ErrUnsupportedFixtureID + } +} + +func buildBrokerStoreWaiting(rootDir string) (BrokerStoreFixtureResult, error) { + store, err := artifacts.NewStore(rootDir) + if err != nil { + return BrokerStoreFixtureResult{}, err + } + seedTime := time.Date(2026, time.March, 18, 9, 0, 0, 0, time.UTC) + appendResult, err := store.AppendSessionExecutionTrigger(waitingSessionExecutionTriggerAppendRequest(seedTime)) + if err != nil { + return BrokerStoreFixtureResult{}, err + } + if _, err := store.UpdateSessionTurnExecution(artifacts.SessionTurnExecutionUpdateRequest{ + SessionID: "sess-manual-multiwait", + TurnID: appendResult.TurnExecution.TurnID, + ExecutionState: "waiting", + WaitKind: "approval", + WaitState: "awaiting_review", + BlockedReasonCode: "approval_wait", + OccurredAt: seedTime.Add(10 * time.Second), + }); err != nil { + return BrokerStoreFixtureResult{}, err + } + return BrokerStoreFixtureResult{ + FixtureID: FixtureTUIWaitingV1, + SessionID: "sess-manual-multiwait", + TurnID: appendResult.TurnExecution.TurnID, + RootDir: rootDir, + }, nil +} + +func waitingSessionExecutionTriggerAppendRequest(seedTime time.Time) artifacts.SessionExecutionTriggerAppendRequest { + return artifacts.SessionExecutionTriggerAppendRequest{ + SessionID: "sess-manual-multiwait", + WorkspaceID: "workspace-local", + AuthoritativeRepositoryRoot: "/workspace/repo", + TriggerSource: "interactive_user", + RequestedOperation: "start", + WorkflowRouting: artifacts.SessionWorkflowPackRoutingDurableState{ + WorkflowFamily: "runecontext", + WorkflowOperation: "approved_change_implementation", + }, + ExecutionState: "waiting", + WaitKind: "approval", + WaitState: "awaiting_review", + BlockedReasonCode: "approval_wait", + UserMessageContentText: "WAITING session=sess-manual-multiwait", + OccurredAt: seedTime, + } +} diff --git a/internal/perffixtures/broker_store_test.go b/internal/perffixtures/broker_store_test.go new file mode 100644 index 00000000..b2208e48 --- /dev/null +++ b/internal/perffixtures/broker_store_test.go @@ -0,0 +1,64 @@ +package perffixtures + +import ( + "testing" + + "github.com/runecode-ai/runecode/internal/artifacts" +) + +func TestBuildBrokerStoreFixtureEmptyAndWaiting(t *testing.T) { + t.Run(FixtureTUIEmptyV1, testEmptyBrokerStoreFixture) + t.Run(FixtureTUIWaitingV1, testWaitingBrokerStoreFixture) +} + +func testEmptyBrokerStoreFixture(t *testing.T) { + t.Helper() + store, _, err := buildFixtureStore(t, FixtureTUIEmptyV1) + if err != nil { + t.Fatalf("buildFixtureStore returned error: %v", err) + } + if states := store.SessionDurableStates(); len(states) != 0 { + t.Fatalf("empty fixture sessions = %d, want 0", len(states)) + } +} + +func testWaitingBrokerStoreFixture(t *testing.T) { + t.Helper() + store, result, err := buildFixtureStore(t, FixtureTUIWaitingV1) + if err != nil { + t.Fatalf("buildFixtureStore returned error: %v", err) + } + state, ok := store.SessionState(result.SessionID) + if !ok { + t.Fatalf("SessionState(%q) ok=false", result.SessionID) + } + if state.WorkPosture != "waiting" { + t.Fatalf("work_posture = %q, want waiting", state.WorkPosture) + } + if len(state.TurnExecutions) != 1 { + t.Fatalf("turn_executions len = %d, want 1", len(state.TurnExecutions)) + } + if got := state.TurnExecutions[0].ExecutionState; got != "waiting" { + t.Fatalf("execution_state = %q, want waiting", got) + } +} + +func buildFixtureStore(t *testing.T, fixtureID string) (*artifacts.Store, BrokerStoreFixtureResult, error) { + t.Helper() + root := t.TempDir() + result, err := BuildBrokerStoreFixture(root, fixtureID) + if err != nil { + return nil, BrokerStoreFixtureResult{}, err + } + store, err := artifacts.NewStore(root) + if err != nil { + return nil, BrokerStoreFixtureResult{}, err + } + return store, result, nil +} + +func TestBuildBrokerStoreFixtureRejectsUnknown(t *testing.T) { + if _, err := BuildBrokerStoreFixture(t.TempDir(), "unknown"); err == nil { + t.Fatal("BuildBrokerStoreFixture error = nil, want unsupported fixture") + } +} diff --git a/internal/perffixtures/errors.go b/internal/perffixtures/errors.go new file mode 100644 index 00000000..562fc6ed --- /dev/null +++ b/internal/perffixtures/errors.go @@ -0,0 +1,5 @@ +package perffixtures + +import "errors" + +var ErrUnsupportedFixtureID = errors.New("unsupported fixture id") diff --git a/internal/perffixtures/runner.go b/internal/perffixtures/runner.go new file mode 100644 index 00000000..0d45d3dc --- /dev/null +++ b/internal/perffixtures/runner.go @@ -0,0 +1,32 @@ +package perffixtures + +import ( + "fmt" + "os" + "path/filepath" +) + +const FixtureRunnerBoundaryMinimal = "runner.boundary.minimal.v1" + +type RunnerFixtureResult struct { + FixtureID string + RootDir string + WorkflowFilePath string + WorkspaceDir string +} + +func BuildRunnerFixture(rootDir string, fixtureID string) (RunnerFixtureResult, error) { + if fixtureID != FixtureRunnerBoundaryMinimal { + return RunnerFixtureResult{}, fmt.Errorf("%w: %s", ErrUnsupportedFixtureID, fixtureID) + } + workspace := filepath.Join(rootDir, "runner-workspace") + if err := os.MkdirAll(workspace, 0o755); err != nil { + return RunnerFixtureResult{}, err + } + workflowPath := filepath.Join(rootDir, "workflow.json") + workflow := `{"schema_id":"runecode.protocol.runner.workflow.v1","name":"minimal","steps":[{"id":"step-1","kind":"noop","inputs":{}}]}` + if err := os.WriteFile(workflowPath, []byte(workflow), 0o644); err != nil { + return RunnerFixtureResult{}, err + } + return RunnerFixtureResult{FixtureID: fixtureID, RootDir: rootDir, WorkflowFilePath: workflowPath, WorkspaceDir: workspace}, nil +} diff --git a/internal/perffixtures/stubs.go b/internal/perffixtures/stubs.go new file mode 100644 index 00000000..dc83f747 --- /dev/null +++ b/internal/perffixtures/stubs.go @@ -0,0 +1,43 @@ +package perffixtures + +import "context" + +type StubProviderBackend struct{} + +type StubProviderRequest struct { + Prompt string +} + +type StubProviderResponse struct { + Text string + StatusCode int + ProviderLatencyMillis int +} + +func (StubProviderBackend) Invoke(_ context.Context, _ StubProviderRequest) StubProviderResponse { + return StubProviderResponse{Text: "stubbed provider response", StatusCode: 200, ProviderLatencyMillis: 7} +} + +type StubSecretsBackend struct{} + +func (StubSecretsBackend) IssueLease(runID string, providerID string) string { + return "lease.stub." + runID + "." + providerID +} + +type StubExternalAnchorTarget struct{} + +func (StubExternalAnchorTarget) Prepare() string { + return "prepared" +} + +func (StubExternalAnchorTarget) ExecuteFastComplete() string { + return "completed" +} + +func (StubExternalAnchorTarget) ExecuteDeferred() string { + return "deferred" +} + +func (StubExternalAnchorTarget) AdmitReceipt() string { + return "admitted" +} diff --git a/internal/perffixtures/stubs_test.go b/internal/perffixtures/stubs_test.go new file mode 100644 index 00000000..726a7eae --- /dev/null +++ b/internal/perffixtures/stubs_test.go @@ -0,0 +1,25 @@ +package perffixtures + +import ( + "context" + "testing" +) + +func TestStubProviderSecretsAndExternalAnchorDeterministic(t *testing.T) { + provider := StubProviderBackend{} + resp := provider.Invoke(context.Background(), StubProviderRequest{Prompt: "hello"}) + if resp.StatusCode != 200 || resp.Text == "" || resp.ProviderLatencyMillis <= 0 { + t.Fatalf("provider response = %#v, want deterministic non-empty success", resp) + } + + secrets := StubSecretsBackend{} + lease := secrets.IssueLease("run-1", "provider-1") + if lease != "lease.stub.run-1.provider-1" { + t.Fatalf("lease = %q, want deterministic lease id", lease) + } + + anchor := StubExternalAnchorTarget{} + if anchor.Prepare() != "prepared" || anchor.ExecuteFastComplete() != "completed" || anchor.ExecuteDeferred() != "deferred" || anchor.AdmitReceipt() != "admitted" { + t.Fatal("external anchor stub returned unexpected state") + } +} diff --git a/internal/perffixtures/workflow.go b/internal/perffixtures/workflow.go new file mode 100644 index 00000000..529aa4d5 --- /dev/null +++ b/internal/perffixtures/workflow.go @@ -0,0 +1,135 @@ +package perffixtures + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +const ( + FixtureWorkflowFirstPartyMinimal = "workflow.first-party-minimal.v1" + FixtureWorkflowCHG050Compile = "workflow.chg050-compile.v1" +) + +type WorkflowFixtureResult struct { + FixtureID string + RootDir string + RunPlan string + Workspace string +} + +func BuildWorkflowFixture(rootDir, fixtureID string) (WorkflowFixtureResult, error) { + workspace := filepath.Join(rootDir, "workspace") + runplan := filepath.Join(rootDir, "runplan.json") + if err := os.MkdirAll(workspace, 0o755); err != nil { + return WorkflowFixtureResult{}, err + } + var runplanContent []byte + switch fixtureID { + case FixtureWorkflowFirstPartyMinimal: + raw, err := json.MarshalIndent(validRunPlanFixture("workflow.first-party-minimal.v1", "workflow_first_party_minimal", "process_first_party_minimal"), "", " ") + if err != nil { + return WorkflowFixtureResult{}, err + } + runplanContent = raw + case FixtureWorkflowCHG050Compile: + raw, err := json.MarshalIndent(validRunPlanFixture("workflow.chg050-compile.v1", "workflow_chg050_compile", "process_chg050_compile"), "", " ") + if err != nil { + return WorkflowFixtureResult{}, err + } + runplanContent = raw + default: + return WorkflowFixtureResult{}, fmt.Errorf("%w: %s", ErrUnsupportedFixtureID, fixtureID) + } + if err := os.WriteFile(runplan, runplanContent, 0o644); err != nil { + return WorkflowFixtureResult{}, err + } + return WorkflowFixtureResult{FixtureID: fixtureID, RootDir: rootDir, RunPlan: runplan, Workspace: workspace}, nil +} + +func validRunPlanFixture(fixtureID, workflowID, processID string) map[string]any { + gate := runPlanGateContract() + gateDefinition := runPlanGateDefinition(gate) + entryDefinition := runPlanEntryDefinition(gate) + return map[string]any{ + "schema_id": "runecode.protocol.v0.RunPlan", + "schema_version": "0.4.0", + "plan_id": "plan_" + workflowID, + "run_id": "run_" + workflowID, + "workflow_id": workflowID, + "workflow_version": "1.0.0", + "process_id": processID, + "approval_profile": "moderate", + "autonomy_posture": "balanced", + "workflow_definition_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "process_definition_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "policy_context_hash": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "compiled_at": "2026-01-01T00:00:00Z", + "role_instance_ids": []string{"role_alpha"}, + "executor_bindings": []map[string]any{{ + "binding_id": "binding_alpha", + "executor_id": "executor_alpha", + "executor_class": "workspace_ordinary", + "allowed_role_kinds": []string{"developer"}, + }}, + "gate_definitions": []map[string]any{gateDefinition}, + "dependency_edges": []any{}, + "entries": []map[string]any{entryDefinition}, + } +} + +func runPlanGateDefinition(gate map[string]any) map[string]any { + return map[string]any{ + "schema_id": "runecode.protocol.v0.GateDefinition", + "schema_version": "0.2.0", + "gate": gate, + "checkpoint_code": "quality", + "order_index": 0, + "stage_id": "quality_stage", + "step_id": "quality_lint", + "role_instance_id": "role_alpha", + "executor_binding_id": "binding_alpha", + "dependency_cache_handoffs": fixtureDependencyCacheHandoffs(), + } +} + +func runPlanEntryDefinition(gate map[string]any) map[string]any { + return map[string]any{ + "entry_id": "quality_lint", + "entry_kind": "gate", + "order_index": 0, + "stage_id": "quality_stage", + "step_id": "quality_lint", + "role_instance_id": "role_alpha", + "executor_binding_id": "binding_alpha", + "checkpoint_code": "quality", + "gate": gate, + "dependency_cache_handoffs": fixtureDependencyCacheHandoffs(), + "depends_on_entry_ids": []string{}, + "blocks_entry_ids": []string{}, + "supported_wait_kinds": []string{"waiting_operator_input", "waiting_approval"}, + } +} + +func runPlanGateContract() map[string]any { + return map[string]any{ + "schema_id": "runecode.protocol.v0.GateContract", + "schema_version": "0.1.0", + "gate_id": "lint", + "gate_kind": "lint", + "gate_version": "0.1.0", + "normalized_inputs": []any{}, + "plan_binding": map[string]any{"checkpoint_code": "quality", "order_index": 0}, + "retry_semantics": map[string]any{"retry_mode": "new_attempt_required", "max_attempts": 2}, + "override_semantics": map[string]any{"override_mode": "policy_action_required", "action_kind": "action_gate_override", "approval_trigger_code": "gate_override"}, + } +} + +func fixtureDependencyCacheHandoffs() []map[string]any { + return []map[string]any{{ + "request_digest": map[string]any{"hash_alg": "sha256", "hash": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"}, + "consumer_role": "workspace", + "required": true, + }} +} diff --git a/internal/perffixtures/workflow_test.go b/internal/perffixtures/workflow_test.go new file mode 100644 index 00000000..f15bce1a --- /dev/null +++ b/internal/perffixtures/workflow_test.go @@ -0,0 +1,24 @@ +package perffixtures + +import ( + "os" + "testing" +) + +func TestBuildWorkflowFixture(t *testing.T) { + for _, fixtureID := range []string{FixtureWorkflowFirstPartyMinimal, FixtureWorkflowCHG050Compile} { + fixtureID := fixtureID + t.Run(fixtureID, func(t *testing.T) { + result, err := BuildWorkflowFixture(t.TempDir(), fixtureID) + if err != nil { + t.Fatalf("BuildWorkflowFixture returned error: %v", err) + } + if _, err := os.Stat(result.RunPlan); err != nil { + t.Fatalf("runplan missing: %v", err) + } + if _, err := os.Stat(result.Workspace); err != nil { + t.Fatalf("workspace missing: %v", err) + } + }) + } +} diff --git a/internal/projectsubstrate/contract.go b/internal/projectsubstrate/contract.go index 57eb0610..7b240748 100644 --- a/internal/projectsubstrate/contract.go +++ b/internal/projectsubstrate/contract.go @@ -24,7 +24,12 @@ const ( ContractVersionV0 = "v0" CanonicalConfigPath = "runecontext.yaml" CanonicalSourcePath = "runecontext" + CanonicalChangesPath = "runecontext/changes" + CanonicalSpecsPath = "runecontext/specs" CanonicalAssurancePath = "runecontext/assurance" + CanonicalChangeProposalName = "proposal.md" + CanonicalChangeTasksName = "tasks.md" + CanonicalChangeStatusName = "status.yaml" canonicalAssuranceBaselinePath = "runecontext/assurance/baseline.yaml" validationStateValid = "valid" diff --git a/internal/protocolschema/run_plan_fixture_minimality_test.go b/internal/protocolschema/run_plan_fixture_minimality_test.go new file mode 100644 index 00000000..475a5463 --- /dev/null +++ b/internal/protocolschema/run_plan_fixture_minimality_test.go @@ -0,0 +1,149 @@ +package protocolschema + +import "testing" + +func TestRunPlanValidMinimalFixtureUsesRequiredFieldsOnly(t *testing.T) { + fixture := loadJSONMap(t, fixturePath(t, "schema/run-plan.valid-minimal.json")) + assertRunPlanFixtureTopLevelMinimal(t, fixture) + assertRunPlanFixtureExecutorBindingMinimal(t, fixture) + assertRunPlanFixtureGateDefinitionMinimal(t, fixture) + assertRunPlanFixtureEntryMinimal(t, fixture) + assertRunPlanFixtureGateDefinitionNormalizedInputsEmpty(t, fixture) +} + +func assertRunPlanFixtureTopLevelMinimal(t *testing.T, fixture map[string]any) { + t.Helper() + + assertSameStringSet(t, sortedKeys(fixture), []string{ + "approval_profile", + "autonomy_posture", + "compiled_at", + "dependency_edges", + "entries", + "executor_bindings", + "gate_definitions", + "plan_id", + "policy_context_hash", + "process_definition_hash", + "process_id", + "role_instance_ids", + "run_id", + "schema_id", + "schema_version", + "workflow_definition_hash", + "workflow_id", + "workflow_version", + }) +} + +func assertRunPlanFixtureExecutorBindingMinimal(t *testing.T, fixture map[string]any) { + t.Helper() + executorBindings, err := requiredArrayValue(fixture, "executor_bindings") + if err != nil { + t.Fatalf("requiredArrayValue(executor_bindings): %v", err) + } + executorBinding, err := objectFromFixtureValue(executorBindings[0], "executor_bindings[0]") + if err != nil { + t.Fatalf("objectFromFixtureValue(executor_bindings[0]): %v", err) + } + assertSameStringSet(t, sortedKeys(executorBinding), []string{ + "allowed_role_kinds", + "binding_id", + "executor_class", + "executor_id", + }) +} + +func assertRunPlanFixtureGateDefinitionMinimal(t *testing.T, fixture map[string]any) { + t.Helper() + gateDefinitions, err := requiredArrayValue(fixture, "gate_definitions") + if err != nil { + t.Fatalf("requiredArrayValue(gate_definitions): %v", err) + } + gateDefinition, err := objectFromFixtureValue(gateDefinitions[0], "gate_definitions[0]") + if err != nil { + t.Fatalf("objectFromFixtureValue(gate_definitions[0]): %v", err) + } + assertSameStringSet(t, sortedKeys(gateDefinition), []string{ + "checkpoint_code", + "executor_binding_id", + "gate", + "order_index", + "role_instance_id", + "schema_id", + "schema_version", + "stage_id", + "step_id", + }) + assertRunPlanFixtureGateContractMinimal(t, objectValue(t, gateDefinition, "gate"), "gate_definitions[0].gate") +} + +func assertRunPlanFixtureEntryMinimal(t *testing.T, fixture map[string]any) { + t.Helper() + entries, err := requiredArrayValue(fixture, "entries") + if err != nil { + t.Fatalf("requiredArrayValue(entries): %v", err) + } + entry, err := objectFromFixtureValue(entries[0], "entries[0]") + if err != nil { + t.Fatalf("objectFromFixtureValue(entries[0]): %v", err) + } + assertSameStringSet(t, sortedKeys(entry), []string{ + "blocks_entry_ids", + "checkpoint_code", + "depends_on_entry_ids", + "entry_id", + "entry_kind", + "executor_binding_id", + "gate", + "order_index", + "role_instance_id", + "stage_id", + "step_id", + "supported_wait_kinds", + }) + assertRunPlanFixtureGateContractMinimal(t, objectValue(t, entry, "gate"), "entries[0].gate") +} + +func assertRunPlanFixtureGateDefinitionNormalizedInputsEmpty(t *testing.T, fixture map[string]any) { + t.Helper() + gateDefinitions, err := requiredArrayValue(fixture, "gate_definitions") + if err != nil { + t.Fatalf("requiredArrayValue(gate_definitions): %v", err) + } + gateDefinition, err := objectFromFixtureValue(gateDefinitions[0], "gate_definitions[0]") + if err != nil { + t.Fatalf("objectFromFixtureValue(gate_definitions[0]): %v", err) + } + normalizedInputs, err := requiredArrayValue(objectValue(t, gateDefinition, "gate"), "normalized_inputs") + if err != nil { + t.Fatalf("requiredArrayValue(gate_definitions[0].gate.normalized_inputs): %v", err) + } + if len(normalizedInputs) != 0 { + t.Fatalf("gate_definitions[0].gate.normalized_inputs length = %d, want 0", len(normalizedInputs)) + } +} + +func assertRunPlanFixtureGateContractMinimal(t *testing.T, gate map[string]any, location string) { + t.Helper() + + assertSameStringSet(t, sortedKeys(gate), []string{ + "gate_id", + "gate_kind", + "gate_version", + "normalized_inputs", + "override_semantics", + "plan_binding", + "retry_semantics", + "schema_id", + "schema_version", + }) + + normalizedInputs, err := requiredArrayValue(gate, "normalized_inputs") + if err != nil { + t.Fatalf("requiredArrayValue(%s.normalized_inputs): %v", location, err) + } + if len(normalizedInputs) != 0 { + t.Fatalf("%s.normalized_inputs length = %d, want 0", location, len(normalizedInputs)) + } +} diff --git a/internal/protocolschema/runecontext_artifacts_validation_test.go b/internal/protocolschema/runecontext_artifacts_validation_test.go index 4b105e06..6cbbfed2 100644 --- a/internal/protocolschema/runecontext_artifacts_validation_test.go +++ b/internal/protocolschema/runecontext_artifacts_validation_test.go @@ -39,19 +39,21 @@ func TestRuneContextApprovedImplementationInputSetSchemaValidateMinimalAndReject schema := mustCompileObjectSchema(t, bundle, "objects/RuneContextApprovedImplementationInputSet.schema.json") valid := map[string]any{ - "schema_id": "runecode.protocol.v0.RuneContextApprovedImplementationInputSet", - "schema_version": "0.1.0", - "input_set_digest": testDigestValue("a"), - "approved_input_digests": []any{testDigestValue("b")}, - "workflow_definition_hash": testDigestValue("c"), - "process_definition_hash": testDigestValue("d"), - "approval_profile": "moderate", - "autonomy_posture": "operator_guided", - "validated_project_substrate_digest": testDigestValue("e"), - "project_substrate_snapshot_digest": testDigestValue("f"), - "control_input_digest": testDigestValue("1"), - "repo_identity_digest": testDigestValue("2"), - "repo_state_identity_digest": testDigestValue("3"), + "schema_id": "runecode.protocol.v0.RuneContextApprovedImplementationInputSet", + "schema_version": "0.1.0", + "input_set_digest": testDigestValue("a"), + "approved_input_digests": []any{testDigestValue("b")}, + "workspace_mutation_digests": []any{testDigestValue("b")}, + "lifecycle_metadata_mutation_digests": []any{testDigestValue("c")}, + "workflow_definition_hash": testDigestValue("c"), + "process_definition_hash": testDigestValue("d"), + "approval_profile": "moderate", + "autonomy_posture": "operator_guided", + "validated_project_substrate_digest": testDigestValue("e"), + "project_substrate_snapshot_digest": testDigestValue("f"), + "control_input_digest": testDigestValue("1"), + "repo_identity_digest": testDigestValue("2"), + "repo_state_identity_digest": testDigestValue("3"), } if err := schema.Validate(valid); err != nil { diff --git a/internal/runnerworkflowperf/harness.go b/internal/runnerworkflowperf/harness.go new file mode 100644 index 00000000..b8e56042 --- /dev/null +++ b/internal/runnerworkflowperf/harness.go @@ -0,0 +1,136 @@ +package runnerworkflowperf + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/runecode-ai/runecode/internal/perfcontracts" + "github.com/runecode-ai/runecode/internal/projectsubstrate" +) + +const CheckSchemaVersion = "runecode.performance.check.v1" + +type HarnessConfig struct { + RepositoryRoot string + CommandTimeout time.Duration + CommandRunner func(repoRoot string, timeout time.Duration, args ...string) (float64, error) +} + +type runnerMeasurementSpec struct { + metricID string + mode string + fixture string +} + +func Run(cfg HarnessConfig) (perfcontracts.CheckOutput, error) { + repoRoot, err := resolveRepoRoot(cfg.RepositoryRoot) + if err != nil { + return perfcontracts.CheckOutput{}, err + } + timeout := resolvedTimeout(cfg.CommandTimeout) + runner := cfg.CommandRunner + if runner == nil { + runner = measureRunnerCommand + } + measurements, err := collectAllMeasurements(repoRoot, timeout, runner) + if err != nil { + return perfcontracts.CheckOutput{}, err + } + return perfcontracts.CheckOutput{SchemaVersion: CheckSchemaVersion, Measurements: measurements}, nil +} + +func resolvedTimeout(timeout time.Duration) time.Duration { + if timeout <= 0 { + return 2 * time.Minute + } + return timeout +} + +func collectAllMeasurements( + repoRoot string, + timeout time.Duration, + runner func(repoRoot string, timeout time.Duration, args ...string) (float64, error), +) ([]perfcontracts.MeasurementRecord, error) { + measurements := make([]perfcontracts.MeasurementRecord, 0, 12) + if err := appendRunnerCheckMeasurements(&measurements, repoRoot, timeout, runner); err != nil { + return nil, err + } + if err := appendWorkflowMeasurements(&measurements, repoRoot, timeout, runner); err != nil { + return nil, err + } + if err := appendCHG050Measurements(&measurements, repoRoot, timeout, runner); err != nil { + return nil, err + } + return measurements, nil +} + +func appendRunnerCheckMeasurements( + measurements *[]perfcontracts.MeasurementRecord, + repoRoot string, + timeout time.Duration, + runner func(repoRoot string, timeout time.Duration, args ...string) (float64, error), +) error { + items, err := collectRunnerCheckMeasurements(repoRoot, timeout, runner) + if err != nil { + return err + } + *measurements = append(*measurements, items...) + return nil +} + +func appendWorkflowMeasurements( + measurements *[]perfcontracts.MeasurementRecord, + repoRoot string, + timeout time.Duration, + runner func(repoRoot string, timeout time.Duration, args ...string) (float64, error), +) error { + items, err := collectMinimalWorkflowMeasurements(repoRoot, timeout, runner) + if err != nil { + return err + } + *measurements = append(*measurements, items...) + return nil +} + +func appendCHG050Measurements( + measurements *[]perfcontracts.MeasurementRecord, + repoRoot string, + timeout time.Duration, + runner func(repoRoot string, timeout time.Duration, args ...string) (float64, error), +) error { + items, err := measureCHG050CompileAndLoad(repoRoot, runner, timeout) + if err != nil { + return err + } + *measurements = append(*measurements, items...) + return nil +} + +func resolveRepoRoot(explicit string) (string, error) { + repoRoot := strings.TrimSpace(explicit) + if repoRoot != "" { + return validateRepoRoot(repoRoot) + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return validateRepoRoot(cwd) +} + +func validateRepoRoot(root string) (string, error) { + clean := filepath.Clean(root) + if _, err := os.Stat(filepath.Join(clean, "runner", "package.json")); err != nil { + return "", fmt.Errorf("repository root missing runner/package.json: %w", err) + } + if _, err := os.Stat(filepath.Join(clean, "protocol", "schemas")); err != nil { + return "", fmt.Errorf("repository root missing protocol/schemas: %w", err) + } + if _, err := projectsubstrate.DiscoverAndValidate(projectsubstrate.DiscoveryInput{RepositoryRoot: clean, Authority: projectsubstrate.RepoRootAuthorityExplicitConfig}); err != nil { + return "", fmt.Errorf("repository root validation failed: %w", err) + } + return clean, nil +} diff --git a/internal/runnerworkflowperf/harness_chg050.go b/internal/runnerworkflowperf/harness_chg050.go new file mode 100644 index 00000000..80e4d7b6 --- /dev/null +++ b/internal/runnerworkflowperf/harness_chg050.go @@ -0,0 +1,202 @@ +package runnerworkflowperf + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/runecode-ai/runecode/internal/artifacts" + "github.com/runecode-ai/runecode/internal/perfcontracts" + "github.com/runecode-ai/runecode/internal/perffixtures" + "github.com/runecode-ai/runecode/internal/policyengine" + "github.com/runecode-ai/runecode/internal/runplan" +) + +func measureCHG050CompileAndLoad(repoRoot string, runner func(repoRoot string, timeout time.Duration, args ...string) (float64, error), timeout time.Duration) ([]perfcontracts.MeasurementRecord, error) { + tmpRoot, err := os.MkdirTemp("", "runecode-runnerworkflowperf-chg050-") + if err != nil { + return nil, err + } + defer func() { _ = os.RemoveAll(tmpRoot) }() + + fixture, err := perffixtures.BuildWorkflowFixture(filepath.Join(tmpRoot, "fixture"), perffixtures.FixtureWorkflowCHG050Compile) + if err != nil { + return nil, fmt.Errorf("build CHG-050 fixture: %w", err) + } + compileMS, validationCanonicalizationMS, persistLoadMS, err := buildPersistAndLoadCHG050(tmpRoot) + if err != nil { + return nil, err + } + startupMS, err := runner(repoRoot, timeout, "node", "--experimental-strip-types", "scripts/perf-runner-workflow.js", "--mode", "immutable-startup", "--runplan", fixture.RunPlan) + if err != nil { + return nil, fmt.Errorf("measure immutable runplan startup: %w", err) + } + return []perfcontracts.MeasurementRecord{ + {MetricID: "metric.workflow.chg050.compile.wall_ms", Value: compileMS, Unit: "ms"}, + {MetricID: "metric.workflow.chg050.validation_canonicalization.wall_ms", Value: validationCanonicalizationMS, Unit: "ms"}, + {MetricID: "metric.workflow.chg050.runplan_persist_load.wall_ms", Value: persistLoadMS, Unit: "ms"}, + {MetricID: "metric.workflow.chg050.runner_start_immutable_runplan.wall_ms", Value: startupMS, Unit: "ms"}, + }, nil +} + +func buildPersistAndLoadCHG050(tmpRoot string) (float64, float64, float64, error) { + compileInput, validationCanonicalizationMS, err := buildCHG050CompileInput() + if err != nil { + return 0, 0, 0, err + } + compileStart := time.Now() + plan, err := runplan.Compile(compileInput) + if err != nil { + return 0, 0, 0, fmt.Errorf("compile CHG-050 runplan: %w", err) + } + compileMS := float64(time.Since(compileStart).Milliseconds()) + persistLoadMS, err := persistAndLoadCHG050RunPlan(tmpRoot, plan) + if err != nil { + return 0, 0, 0, err + } + return compileMS, validationCanonicalizationMS, persistLoadMS, nil +} + +func buildCHG050CompileInput() (runplan.CompileInput, float64, error) { + validationCanonicalizationStart := time.Now() + processBytes, processHash, err := marshalCHG050ProcessDefinition() + if err != nil { + return runplan.CompileInput{}, 0, err + } + workflowBytes, err := marshalCHG050WorkflowDefinition(processHash) + if err != nil { + return runplan.CompileInput{}, 0, err + } + validationCanonicalizationMS := float64(time.Since(validationCanonicalizationStart).Milliseconds()) + return runplan.CompileInput{RunID: "run-chg050", PlanID: "plan-chg050-v1", CompiledAt: time.Date(2026, time.March, 20, 10, 0, 0, 0, time.UTC), WorkflowDefinitionBytes: workflowBytes, ProcessDefinitionBytes: processBytes, ProjectContextIdentityDigest: "sha256:" + strings.Repeat("3", 64), PolicyContextHash: "sha256:" + strings.Repeat("4", 64), ExecutorRegistry: policyengine.BuildExecutorRegistryProjection()}, validationCanonicalizationMS, nil +} + +func marshalCHG050ProcessDefinition() ([]byte, string, error) { + processDefinition := map[string]any{"schema_id": "runecode.protocol.v0.ProcessDefinition", "schema_version": "0.4.0", "process_id": "process_chg050", "executor_bindings": []map[string]any{{"binding_id": "binding_workspace_runner", "executor_id": "workspace-runner", "executor_class": "workspace_ordinary", "allowed_role_kinds": []string{"workspace-edit"}}}, "gate_definitions": []map[string]any{{"schema_id": "runecode.protocol.v0.GateDefinition", "schema_version": "0.2.0", "checkpoint_code": "step_validation_started", "order_index": 0, "stage_id": "validation", "step_id": "validate_step", "role_instance_id": "workspace_editor_1", "executor_binding_id": "binding_workspace_runner", "gate": map[string]any{"schema_id": "runecode.protocol.v0.GateContract", "schema_version": "0.1.0", "gate_id": "lint_gate", "gate_kind": "lint", "gate_version": "1.0.0", "normalized_inputs": []map[string]any{{"input_id": "source_tree", "input_digest": "sha256:" + strings.Repeat("2", 64)}}, "plan_binding": map[string]any{"checkpoint_code": "step_validation_started", "order_index": 0}, "retry_semantics": map[string]any{"retry_mode": "new_attempt_required", "max_attempts": 2}, "override_semantics": map[string]any{"override_mode": "policy_action_required", "action_kind": "action_gate_override", "approval_trigger_code": "gate_override"}}}}, "dependency_edges": []map[string]any{}} + processBytes, err := json.Marshal(processDefinition) + if err != nil { + return nil, "", err + } + processCanonical, err := policyengine.CanonicalizeJSONBytes(processBytes) + if err != nil { + return nil, "", err + } + return processBytes, policyengine.HashCanonicalJSONBytes(processCanonical), nil +} + +func marshalCHG050WorkflowDefinition(processHash string) ([]byte, error) { + workflowDefinition := map[string]any{"schema_id": "runecode.protocol.v0.WorkflowDefinition", "schema_version": "0.5.0", "workflow_id": "workflow_chg050", "workflow_version": "1.0.0", "selected_process_id": "process_chg050", "selected_process_definition_hash": processHash, "reviewed_process_artifacts": []map[string]any{{"process_id": "process_chg050", "process_definition_hash": processHash}}, "approval_profile": "moderate", "autonomy_posture": "balanced"} + workflowBytes, err := json.Marshal(workflowDefinition) + if err != nil { + return nil, err + } + _, err = policyengine.CanonicalizeJSONBytes(workflowBytes) + if err != nil { + return nil, err + } + return workflowBytes, nil +} + +func persistAndLoadCHG050RunPlan(tmpRoot string, plan runplan.RunPlan) (float64, error) { + planBytes, err := json.Marshal(plan) + if err != nil { + return 0, err + } + persistStart := time.Now() + store, err := artifacts.NewStore(filepath.Join(tmpRoot, "store")) + if err != nil { + return 0, err + } + ref, err := store.Put(artifacts.PutRequest{Payload: planBytes, ContentType: "application/json", DataClass: artifacts.DataClassSpecText, ProvenanceReceiptHash: "sha256:" + strings.Repeat("8", 64), CreatedByRole: "brokerapi", TrustedSource: true, RunID: "run-chg050", StepID: "compiled_run_plan/plan-chg050-v1"}) + if err != nil { + return 0, err + } + authority := artifacts.RunPlanAuthorityRecord{RunID: "run-chg050", PlanID: "plan-chg050-v1", RunPlanDigest: ref.Digest, WorkflowDefinitionHash: plan.WorkflowDefinitionHash, ProcessDefinitionHash: plan.ProcessDefinitionHash, PolicyContextHash: plan.PolicyContextHash, ProjectContextIdentityDigest: plan.ProjectContextIdentityDigest, CompiledAt: time.Date(2026, time.March, 20, 10, 0, 0, 0, time.UTC), Entries: authorityEntriesFromPlan(plan)} + compilation := artifacts.RunPlanCompilationRecord{RunID: "run-chg050", PlanID: "plan-chg050-v1", RunPlanDigest: ref.Digest, CompileCacheKey: "cache-key-chg050-v1", WorkflowDefinitionRef: "sha256:" + strings.Repeat("5", 64), ProcessDefinitionRef: "sha256:" + strings.Repeat("6", 64), WorkflowDefinitionHash: plan.WorkflowDefinitionHash, ProcessDefinitionHash: plan.ProcessDefinitionHash, PolicyContextHash: plan.PolicyContextHash, ProjectContextIdentityDigest: plan.ProjectContextIdentityDigest, CompiledAt: time.Date(2026, time.March, 20, 10, 0, 0, 0, time.UTC)} + if err := store.RecordRunPlanAuthority(authority, compilation); err != nil { + return 0, err + } + if err := verifyCHG050Persistence(store); err != nil { + return 0, err + } + return float64(time.Since(persistStart).Milliseconds()), nil +} + +func verifyCHG050Persistence(store *artifacts.Store) error { + if _, ok, err := store.ActiveRunPlanAuthority("run-chg050"); err != nil || !ok { + if err != nil { + return err + } + return fmt.Errorf("active runplan authority missing") + } + if _, ok := store.RunPlanCompilationRecordByCacheKey("cache-key-chg050-v1"); !ok { + return fmt.Errorf("runplan compilation cache lookup missing") + } + return nil +} + +func authorityEntriesFromPlan(plan runplan.RunPlan) []artifacts.RunPlanGateEntryRecord { + if len(plan.Entries) == 0 { + return nil + } + out := make([]artifacts.RunPlanGateEntryRecord, 0, len(plan.Entries)) + for _, entry := range plan.Entries { + out = append(out, artifacts.RunPlanGateEntryRecord{ + EntryID: strings.TrimSpace(entry.EntryID), + EntryKind: strings.TrimSpace(entry.EntryKind), + PlanCheckpointCode: strings.TrimSpace(entry.CheckpointCode), + PlanOrderIndex: entry.OrderIndex, + GateID: strings.TrimSpace(entry.Gate.GateID), + GateKind: strings.TrimSpace(entry.Gate.GateKind), + GateVersion: strings.TrimSpace(entry.Gate.GateVersion), + StageID: strings.TrimSpace(entry.StageID), + StepID: strings.TrimSpace(entry.StepID), + RoleInstanceID: strings.TrimSpace(entry.RoleInstanceID), + MaxAttempts: maxAttemptsFromRetrySemantics(entry.Gate.RetrySemantics), + ExpectedInputDigests: expectedInputDigests(entry.Gate.NormalizedInputs), + }) + } + return out +} + +func expectedInputDigests(inputs []map[string]any) []string { + if len(inputs) == 0 { + return nil + } + seen := map[string]struct{}{} + out := make([]string, 0, len(inputs)) + for _, input := range inputs { + raw, _ := input["input_digest"].(string) + digest := strings.TrimSpace(raw) + if digest == "" { + continue + } + if _, ok := seen[digest]; ok { + continue + } + seen[digest] = struct{}{} + out = append(out, digest) + } + if len(out) == 0 { + return nil + } + sort.Strings(out) + return out +} + +func maxAttemptsFromRetrySemantics(retry map[string]any) int { + if retry == nil { + return 1 + } + if value, ok := retry["max_attempts"].(int); ok && value > 0 { + return value + } + if value, ok := retry["max_attempts"].(float64); ok && int(value) > 0 { + return int(value) + } + return 1 +} diff --git a/internal/runnerworkflowperf/harness_runner.go b/internal/runnerworkflowperf/harness_runner.go new file mode 100644 index 00000000..c9b8136a --- /dev/null +++ b/internal/runnerworkflowperf/harness_runner.go @@ -0,0 +1,148 @@ +package runnerworkflowperf + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/runecode-ai/runecode/internal/perfcontracts" + "github.com/runecode-ai/runecode/internal/perffixtures" +) + +func collectRunnerCheckMeasurements(repoRoot string, timeout time.Duration, runner func(repoRoot string, timeout time.Duration, args ...string) (float64, error)) ([]perfcontracts.MeasurementRecord, error) { + boundaryMS, err := runner(repoRoot, timeout, "npm", "run", "boundary-check") + if err != nil { + return nil, fmt.Errorf("measure boundary-check: %w", err) + } + fixturesMS, err := runner(repoRoot, timeout, "node", "--test", "scripts/protocol-fixtures.test.js") + if err != nil { + return nil, fmt.Errorf("measure protocol-fixtures: %w", err) + } + return []perfcontracts.MeasurementRecord{{MetricID: "metric.runner.boundary_check.wall_ms", Value: boundaryMS, Unit: "ms"}, {MetricID: "metric.runner.protocol_fixtures.wall_ms", Value: fixturesMS, Unit: "ms"}}, nil +} + +func collectMinimalWorkflowMeasurements(repoRoot string, timeout time.Duration, runner func(repoRoot string, timeout time.Duration, args ...string) (float64, error)) ([]perfcontracts.MeasurementRecord, error) { + minimal, cleanup, err := buildMinimalWorkflowFixture() + if err != nil { + return nil, err + } + defer cleanup() + specs := []runnerMeasurementSpec{ + {metricID: "metric.runner.cold_start.minimal_workflow.wall_ms", mode: "cold-start", fixture: minimal.FixtureID}, + {metricID: "metric.workflow.mvp_execution.supported_path.wall_ms", mode: "workflow-path", fixture: minimal.FixtureID}, + {metricID: "metric.workflow.chg049.first_party_beta_slice.wall_ms", mode: "first-party-beta", fixture: minimal.FixtureID}, + } + return collectWorkflowSpecs(repoRoot, timeout, runner, minimal.RunPlan, specs) +} + +func collectWorkflowSpecs( + repoRoot string, + timeout time.Duration, + runner func(repoRoot string, timeout time.Duration, args ...string) (float64, error), + runPlanPath string, + specs []runnerMeasurementSpec, +) ([]perfcontracts.MeasurementRecord, error) { + measurements := make([]perfcontracts.MeasurementRecord, 0, len(specs)) + for _, spec := range specs { + args := []string{"node", "--experimental-strip-types", "scripts/perf-runner-workflow.js", "--mode", spec.mode, "--runplan", runPlanPath} + if fixtureID := strings.TrimSpace(spec.fixture); fixtureID != "" { + args = append(args, "--fixture", fixtureID) + } + wallMS, err := runner(repoRoot, timeout, args...) + if err != nil { + return nil, fmt.Errorf("measure %s: %w", spec.mode, err) + } + measurements = append(measurements, perfcontracts.MeasurementRecord{MetricID: spec.metricID, Value: wallMS, Unit: "ms"}) + } + return measurements, nil +} + +func buildMinimalWorkflowFixture() (perffixtures.WorkflowFixtureResult, func(), error) { + root, err := os.MkdirTemp("", "runecode-runnerworkflowperf-minimal-") + if err != nil { + return perffixtures.WorkflowFixtureResult{}, nil, err + } + cleanup := func() { _ = os.RemoveAll(root) } + fixture, err := perffixtures.BuildWorkflowFixture(root, perffixtures.FixtureWorkflowFirstPartyMinimal) + if err != nil { + cleanup() + return perffixtures.WorkflowFixtureResult{}, nil, fmt.Errorf("build minimal workflow fixture: %w", err) + } + return fixture, cleanup, nil +} + +func measureRunnerCommand(repoRoot string, timeout time.Duration, args ...string) (float64, error) { + if len(args) == 0 { + return 0, fmt.Errorf("command arguments required") + } + if err := validateRunnerExecutable(args[0]); err != nil { + return 0, err + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + runnerDir := filepath.Join(repoRoot, "runner") + started := time.Now() + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Dir = runnerDir + out, err := cmd.CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(out)) + if msg == "" { + msg = err.Error() + } + return 0, fmt.Errorf("%s failed: %s", strings.Join(args, " "), msg) + } + if expectsRunnerScriptMeasurement(args) { + return parseRunnerMeasurement(out) + } + return float64(time.Since(started).Milliseconds()), nil +} + +func validateRunnerExecutable(name string) error { + switch strings.TrimSpace(name) { + case "npm", "node": + return nil + default: + return fmt.Errorf("unsupported runner executable %q", name) + } +} + +func expectsRunnerScriptMeasurement(args []string) bool { + for _, arg := range args { + if arg == "scripts/perf-runner-workflow.js" { + return true + } + } + return false +} + +func parseRunnerMeasurement(out []byte) (float64, error) { + value := lastNumericMeasurementLine(string(out)) + if value == "" { + return 0, fmt.Errorf("runner workflow script returned empty measurement output") + } + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0, fmt.Errorf("parse runner workflow measurement %q: %w", value, err) + } + return parsed, nil +} + +func lastNumericMeasurementLine(raw string) string { + lines := strings.Split(raw, "\n") + for idx := len(lines) - 1; idx >= 0; idx-- { + line := strings.TrimSpace(lines[idx]) + if line == "" { + continue + } + if _, err := strconv.ParseFloat(line, 64); err == nil { + return line + } + } + return strings.TrimSpace(raw) +} diff --git a/internal/runnerworkflowperf/harness_test.go b/internal/runnerworkflowperf/harness_test.go new file mode 100644 index 00000000..e6da0637 --- /dev/null +++ b/internal/runnerworkflowperf/harness_test.go @@ -0,0 +1,188 @@ +package runnerworkflowperf + +import ( + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/runecode-ai/runecode/internal/perfcontracts" +) + +func TestRunProducesPhase4RunnerWorkflowMetrics(t *testing.T) { + repoRoot := runnerWorkflowRepoRoot(t) + out, err := Run(HarnessConfig{RepositoryRoot: repoRoot, CommandRunner: deterministicCommandRunner, CommandTimeout: 10 * time.Second}) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + if out.SchemaVersion != CheckSchemaVersion { + t.Fatalf("schema_version = %q, want %q", out.SchemaVersion, CheckSchemaVersion) + } + required := map[string]string{ + "metric.runner.boundary_check.wall_ms": "ms", + "metric.runner.protocol_fixtures.wall_ms": "ms", + "metric.runner.cold_start.minimal_workflow.wall_ms": "ms", + "metric.workflow.mvp_execution.supported_path.wall_ms": "ms", + "metric.workflow.chg049.first_party_beta_slice.wall_ms": "ms", + "metric.workflow.chg050.compile.wall_ms": "ms", + "metric.workflow.chg050.validation_canonicalization.wall_ms": "ms", + "metric.workflow.chg050.runplan_persist_load.wall_ms": "ms", + "metric.workflow.chg050.runner_start_immutable_runplan.wall_ms": "ms", + } + for metricID, unit := range required { + if !hasMetric(out.Measurements, metricID, unit) { + t.Fatalf("missing metric %s (%s)", metricID, unit) + } + } +} + +func TestRunPassesExpectedWorkflowFixtureForSupportedPathMetrics(t *testing.T) { + repoRoot := runnerWorkflowRepoRoot(t) + var calls [][]string + runner := func(_ string, _ time.Duration, args ...string) (float64, error) { + copied := append([]string(nil), args...) + calls = append(calls, copied) + return deterministicCommandRunner("", 0, args...) + } + if _, err := Run(HarnessConfig{RepositoryRoot: repoRoot, CommandRunner: runner, CommandTimeout: 10 * time.Second}); err != nil { + t.Fatalf("Run returned error: %v", err) + } + assertModeFixtureArg(t, calls, "workflow-path") + assertModeFixtureArg(t, calls, "first-party-beta") +} + +func runnerWorkflowRepoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) +} + +func assertModeFixtureArg(t *testing.T, calls [][]string, mode string) { + t.Helper() + for _, call := range calls { + if !containsArg(call, "--mode", mode) { + continue + } + fixture, ok := argValue(call, "--fixture") + if !ok { + t.Fatalf("mode %s missing --fixture argument", mode) + } + if fixture != "workflow.first-party-minimal.v1" { + t.Fatalf("mode %s fixture %q, want %q", mode, fixture, "workflow.first-party-minimal.v1") + } + return + } + t.Fatalf("no invocation found for mode %s", mode) +} + +func deterministicCommandRunner(_ string, _ time.Duration, args ...string) (float64, error) { + if len(args) == 0 { + return 0, nil + } + if args[0] == "npm" { + return 1100, nil + } + if args[0] != "node" { + return 100, nil + } + if modeLatency, ok := deterministicNodeModeLatency(args); ok { + return modeLatency, nil + } + if isNodeProtocolFixtureCommand(args) { + return 2700, nil + } + return 250, nil +} + +func deterministicNodeModeLatency(args []string) (float64, bool) { + for _, token := range args { + switch token { + case "cold-start": + return 220, true + case "workflow-path": + return 340, true + case "first-party-beta": + return 280, true + case "immutable-startup": + return 310, true + } + } + return 0, false +} + +func isNodeProtocolFixtureCommand(args []string) bool { + return len(args) >= 3 && args[1] == "--test" +} + +func containsArg(args []string, key, value string) bool { + for idx := 0; idx < len(args)-1; idx++ { + if strings.TrimSpace(args[idx]) == key && strings.TrimSpace(args[idx+1]) == value { + return true + } + } + return false +} + +func argValue(args []string, key string) (string, bool) { + for idx := 0; idx < len(args)-1; idx++ { + if strings.TrimSpace(args[idx]) != key { + continue + } + value := strings.TrimSpace(args[idx+1]) + if value == "" { + return "", false + } + return value, true + } + return "", false +} + +func hasMetric(measurements []perfcontracts.MeasurementRecord, metricID, unit string) bool { + for _, m := range measurements { + if m.MetricID == metricID && m.Unit == unit { + return true + } + } + return false +} + +func TestValidateRunnerExecutableRejectsUnexpectedBinary(t *testing.T) { + if err := validateRunnerExecutable("bash"); err == nil { + t.Fatal("validateRunnerExecutable error = nil, want rejection") + } +} + +func TestParseRunnerMeasurement(t *testing.T) { + value, err := parseRunnerMeasurement([]byte("340\n")) + if err != nil { + t.Fatalf("parseRunnerMeasurement returned error: %v", err) + } + if value != 340 { + t.Fatalf("value = %v, want 340", value) + } +} + +func TestParseRunnerMeasurementIgnoresWarningNoise(t *testing.T) { + out := []byte("(node:29695) warning text\nadditional warning context\n0\n") + value, err := parseRunnerMeasurement(out) + if err != nil { + t.Fatalf("parseRunnerMeasurement returned error: %v", err) + } + if value != 0 { + t.Fatalf("value = %v, want 0", value) + } +} + +func TestDeterministicCommandRunnerUsesScriptMeasurementOutput(t *testing.T) { + value, err := deterministicCommandRunner("", 0, "node", "--experimental-strip-types", "scripts/perf-runner-workflow.js", "--mode", "workflow-path") + if err != nil { + t.Fatalf("deterministicCommandRunner returned error: %v", err) + } + if value != 340 { + t.Fatalf("value = %v, want 340", value) + } +} diff --git a/internal/tuiperf/benchparse.go b/internal/tuiperf/benchparse.go new file mode 100644 index 00000000..15d44a32 --- /dev/null +++ b/internal/tuiperf/benchparse.go @@ -0,0 +1,114 @@ +package tuiperf + +import ( + "bufio" + "fmt" + "io" + "regexp" + "strconv" + "strings" + + "github.com/runecode-ai/runecode/internal/perfcontracts" +) + +var benchSuffixPattern = regexp.MustCompile(`-\d+$`) + +type BenchmarkMetricMap struct { + Benchmark string + MetricID string + Unit string + Field string +} + +func ParseGoTestBenchOutput(r io.Reader, mappings []BenchmarkMetricMap) ([]perfcontracts.MeasurementRecord, error) { + mapByKey := benchmarkMappingIndex(mappings) + measurements := make([]perfcontracts.MeasurementRecord, 0, len(mappings)) + seen := map[string]struct{}{} + scanner := bufio.NewScanner(r) + for scanner.Scan() { + fields, ok := benchmarkFields(scanner.Text()) + if !ok { + continue + } + benchName := fields[0] + normalizedBench := benchSuffixPattern.ReplaceAllString(benchName, "") + appendBenchMeasurements(&measurements, seen, mapByKey, benchName, normalizedBench, parseBenchMetrics(fields)) + } + if err := scanner.Err(); err != nil { + return nil, err + } + for _, m := range mappings { + if _, ok := seen[m.MetricID]; !ok { + return nil, fmt.Errorf("missing benchmark measurement for %s", m.MetricID) + } + } + return measurements, nil +} + +func benchmarkMappingIndex(mappings []BenchmarkMetricMap) map[string]BenchmarkMetricMap { + mapByKey := map[string]BenchmarkMetricMap{} + for _, m := range mappings { + k := strings.TrimSpace(m.Benchmark) + ":" + strings.TrimSpace(m.Field) + mapByKey[k] = m + } + return mapByKey +} + +func benchmarkFields(line string) ([]string, bool) { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "Benchmark") { + return nil, false + } + fields := strings.Fields(trimmed) + if len(fields) < 3 { + return nil, false + } + return fields, true +} + +func appendBenchMeasurements(measurements *[]perfcontracts.MeasurementRecord, seen map[string]struct{}, mapByKey map[string]BenchmarkMetricMap, benchName, normalizedBench string, metrics map[string]float64) { + for fieldName, val := range metrics { + mapping, ok := resolveBenchMapping(mapByKey, benchName, normalizedBench, fieldName) + if !ok || metricSeen(seen, mapping.MetricID) { + continue + } + *measurements = append(*measurements, perfcontracts.MeasurementRecord{MetricID: mapping.MetricID, Value: val, Unit: mapping.Unit}) + } +} + +func resolveBenchMapping(mapByKey map[string]BenchmarkMetricMap, benchName, normalizedBench, fieldName string) (BenchmarkMetricMap, bool) { + mapping, ok := mapByKey[benchName+":"+fieldName] + if ok { + return mapping, true + } + mapping, ok = mapByKey[normalizedBench+":"+fieldName] + return mapping, ok +} + +func metricSeen(seen map[string]struct{}, metricID string) bool { + if _, dup := seen[metricID]; dup { + return true + } + seen[metricID] = struct{}{} + return false +} + +func parseBenchMetrics(fields []string) map[string]float64 { + out := map[string]float64{} + for i := 1; i+1 < len(fields); i++ { + value, err := strconv.ParseFloat(fields[i], 64) + if err != nil { + continue + } + unit := fields[i+1] + switch unit { + case "ns/op": + out["ns/op"] = value + case "B/op": + out["B/op"] = value + case "allocs/op": + out["allocs/op"] = value + } + } + return out +} diff --git a/internal/tuiperf/benchparse_test.go b/internal/tuiperf/benchparse_test.go new file mode 100644 index 00000000..6b881d19 --- /dev/null +++ b/internal/tuiperf/benchparse_test.go @@ -0,0 +1,20 @@ +package tuiperf + +import ( + "strings" + "testing" +) + +func TestParseGoTestBenchOutput(t *testing.T) { + input := strings.NewReader("BenchmarkShellViewEmpty-8 12345 11000 ns/op 1500 B/op 20 allocs/op\nBenchmarkShellWatchApply-8 23456 12000 ns/op 1700 B/op 21 allocs/op\n") + measurements, err := ParseGoTestBenchOutput(input, []BenchmarkMetricMap{ + {Benchmark: "BenchmarkShellViewEmpty", Field: "ns/op", MetricID: "metric.tui.render.shell_view_empty.ns_op", Unit: "ns/op"}, + {Benchmark: "BenchmarkShellWatchApply", Field: "ns/op", MetricID: "metric.tui.update.shell_watch_apply.ns_op", Unit: "ns/op"}, + }) + if err != nil { + t.Fatalf("ParseGoTestBenchOutput returned error: %v", err) + } + if len(measurements) != 2 { + t.Fatalf("measurements len = %d, want 2", len(measurements)) + } +} diff --git a/internal/tuiperf/cpu_sampler_linux.go b/internal/tuiperf/cpu_sampler_linux.go new file mode 100644 index 00000000..c3392010 --- /dev/null +++ b/internal/tuiperf/cpu_sampler_linux.go @@ -0,0 +1,234 @@ +//go:build linux + +package tuiperf + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +type CPUSampleConfig struct { + Warmup time.Duration + Window time.Duration + Windows int + TicksPerSecond float64 + CPUCount float64 + ProcRoot string + ExpectedComm string + ChildLookupWait time.Duration + PollInterval time.Duration +} + +type CPUWindowSample struct { + Index int `json:"index"` + CPUPercent float64 `json:"cpu_percent"` +} + +type CPUSampleResult struct { + TargetPID int `json:"target_pid"` + TargetComm string `json:"target_comm"` + WarmupMillis int64 `json:"warmup_millis"` + WindowMillis int64 `json:"observation_window_millis"` + ObservationWindows int `json:"observation_windows"` + AverageCPUPercent float64 `json:"average_cpu_percent"` + MaxCPUPercent float64 `json:"max_cpu_percent"` + Windows []CPUWindowSample `json:"windows"` +} + +func DefaultCPUSampleConfig() CPUSampleConfig { + return CPUSampleConfig{ + Warmup: 3 * time.Second, + Window: 20 * time.Second, + Windows: 3, + ProcRoot: "/proc", + ExpectedComm: "runecode-tui", + ChildLookupWait: 3 * time.Second, + PollInterval: 20 * time.Millisecond, + } +} + +func (c *CPUSampleConfig) normalize() error { + c.applyDefaults() + if err := c.validateDurations(); err != nil { + return err + } + if err := c.resolveTicksPerSecond(); err != nil { + return err + } + return c.resolveCPUCount() +} + +func (c *CPUSampleConfig) applyDefaults() { + if c.ProcRoot == "" { + c.ProcRoot = "/proc" + } + if c.ExpectedComm == "" { + c.ExpectedComm = "runecode-tui" + } + if c.ChildLookupWait <= 0 { + c.ChildLookupWait = 3 * time.Second + } + if c.PollInterval <= 0 { + c.PollInterval = 20 * time.Millisecond + } +} + +func (c *CPUSampleConfig) validateDurations() error { + if c.Warmup < 0 || c.Window <= 0 || c.Windows <= 0 { + return fmt.Errorf("invalid cpu sampling durations") + } + return nil +} + +func (c *CPUSampleConfig) resolveTicksPerSecond() error { + if c.TicksPerSecond > 0 { + return nil + } + tps, err := systemTicksPerSecond() + if err != nil { + return err + } + c.TicksPerSecond = tps + return nil +} + +func (c *CPUSampleConfig) resolveCPUCount() error { + if c.CPUCount > 0 { + return nil + } + cpus, err := cpuCountFromProc(c.ProcRoot) + if err != nil { + return err + } + c.CPUCount = cpus + return nil +} + +func systemTicksPerSecond() (float64, error) { + v := os.Getenv("RUNECODE_TUIPERF_CLK_TCK") + if strings.TrimSpace(v) != "" { + f, err := strconv.ParseFloat(strings.TrimSpace(v), 64) + if err != nil { + return 0, fmt.Errorf("parse RUNECODE_TUIPERF_CLK_TCK: %w", err) + } + if f <= 0 { + return 0, fmt.Errorf("RUNECODE_TUIPERF_CLK_TCK must be > 0") + } + return f, nil + } + return 100.0, nil +} + +func cpuCountFromProc(procRoot string) (float64, error) { + raw, err := os.ReadFile(procRoot + "/stat") + if err != nil { + return 0, err + } + count := 0 + for _, line := range strings.Split(string(raw), "\n") { + if len(line) < 4 { + continue + } + if strings.HasPrefix(line, "cpu") && len(line) > 3 && line[3] >= '0' && line[3] <= '9' { + count++ + } + } + if count <= 0 { + return 0, fmt.Errorf("failed to detect cpu count") + } + return float64(count), nil +} + +func WaitForChildByComm(procRoot string, wrapperPID int, comm string, timeout time.Duration, pollInterval time.Duration) (int, error) { + deadline := time.Now().Add(timeout) + for { + pid, err := FindDescendantByComm(procRoot, wrapperPID, comm) + if err == nil { + return pid, nil + } + if time.Now().After(deadline) { + return 0, err + } + time.Sleep(pollInterval) + } +} + +func SampleProcessCPU(targetPID int, cfg CPUSampleConfig) (CPUSampleResult, error) { + if err := cfg.normalize(); err != nil { + return CPUSampleResult{}, err + } + cpuWindow, err := collectCPUWindows(targetPID, cfg) + if err != nil { + return CPUSampleResult{}, err + } + return cpuWindow.toResult(targetPID, cfg), nil +} + +type cpuWindowCollection struct { + targetComm string + windows []CPUWindowSample + sum float64 + max float64 +} + +func collectCPUWindows(targetPID int, cfg CPUSampleConfig) (cpuWindowCollection, error) { + if cfg.Warmup > 0 { + time.Sleep(cfg.Warmup) + } + out := cpuWindowCollection{windows: make([]CPUWindowSample, 0, cfg.Windows)} + for i := 0; i < cfg.Windows; i++ { + sample, comm, err := sampleCPUWindow(targetPID, cfg, i+1) + if err != nil { + return cpuWindowCollection{}, err + } + if out.targetComm == "" { + out.targetComm = comm + } + if sample.CPUPercent > out.max || i == 0 { + out.max = sample.CPUPercent + } + out.sum += sample.CPUPercent + out.windows = append(out.windows, sample) + } + return out, nil +} + +func sampleCPUWindow(targetPID int, cfg CPUSampleConfig, index int) (CPUWindowSample, string, error) { + before, err := ReadProcStat(cfg.ProcRoot, targetPID) + if err != nil { + return CPUWindowSample{}, "", err + } + time.Sleep(cfg.Window) + after, err := ReadProcStat(cfg.ProcRoot, targetPID) + if err != nil { + return CPUWindowSample{}, "", err + } + cpu := cpuPercentForWindow(before.TotalTicks(), after.TotalTicks(), cfg.Window, cfg.TicksPerSecond, cfg.CPUCount) + return CPUWindowSample{Index: index, CPUPercent: cpu}, before.Comm, nil +} + +func cpuPercentForWindow(beforeTicks, afterTicks uint64, window time.Duration, ticksPerSecond, cpuCount float64) float64 { + deltaTicks := float64(afterTicks - beforeTicks) + deltaSeconds := window.Seconds() + cpu := (deltaTicks / ticksPerSecond / deltaSeconds / cpuCount) * 100.0 + if cpu < 0 { + return 0 + } + return cpu +} + +func (c cpuWindowCollection) toResult(targetPID int, cfg CPUSampleConfig) CPUSampleResult { + return CPUSampleResult{ + TargetPID: targetPID, + TargetComm: c.targetComm, + WarmupMillis: cfg.Warmup.Milliseconds(), + WindowMillis: cfg.Window.Milliseconds(), + ObservationWindows: cfg.Windows, + AverageCPUPercent: c.sum / float64(cfg.Windows), + MaxCPUPercent: c.max, + Windows: c.windows, + } +} diff --git a/internal/tuiperf/cpu_sampler_linux_test.go b/internal/tuiperf/cpu_sampler_linux_test.go new file mode 100644 index 00000000..2c1fe8be --- /dev/null +++ b/internal/tuiperf/cpu_sampler_linux_test.go @@ -0,0 +1,86 @@ +//go:build linux + +package tuiperf + +import ( + "os" + "path/filepath" + "strconv" + "testing" + "time" +) + +func TestSampleProcessCPUDeterministicProcFixture(t *testing.T) { + root := t.TempDir() + pid := 4321 + procDir := filepath.Join(root, strconv.Itoa(pid)) + writeDeterministicCPUFixture(t, root, procDir) + resultCh := make(chan CPUSampleResult, 1) + errCh := make(chan error, 1) + go func() { + result, err := SampleProcessCPU(pid, CPUSampleConfig{ProcRoot: root, Warmup: 0, Window: 5 * time.Millisecond, Windows: 1, TicksPerSecond: 100, CPUCount: 1}) + if err != nil { + errCh <- err + return + } + resultCh <- result + }() + if err := writeFileAtomically(filepath.Join(procDir, "stat"), []byte("4321 (runecode-tui) S 1 2 3 4 5 6 7 8 9 10 11 200 50 0 0 20 0 1 0 999 0 0 0\n"), 0o644); err != nil { + t.Fatalf("writeFileAtomically stat second: %v", err) + } + select { + case err := <-errCh: + t.Fatalf("SampleProcessCPU returned error: %v", err) + case result := <-resultCh: + if result.WindowMillis != 5 { + t.Fatalf("window millis = %d, want 5", result.WindowMillis) + } + if result.AverageCPUPercent < 0 { + t.Fatalf("average cpu = %.2f, want non-negative", result.AverageCPUPercent) + } + case <-time.After(2 * time.Second): + t.Fatal("SampleProcessCPU timed out waiting for deterministic fixture result") + } +} + +func writeDeterministicCPUFixture(t *testing.T, root, procDir string) { + t.Helper() + if err := os.MkdirAll(procDir, 0o755); err != nil { + t.Fatalf("MkdirAll procDir: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "stat"), []byte("cpu 1 2 3\ncpu0 1 1 1\n"), 0o644); err != nil { + t.Fatalf("WriteFile stat: %v", err) + } + if err := os.WriteFile(filepath.Join(procDir, "stat"), []byte("4321 (runecode-tui) S 1 2 3 4 5 6 7 8 9 10 11 100 50 0 0 20 0 1 0 999 0 0 0\n"), 0o644); err != nil { + t.Fatalf("WriteFile stat first: %v", err) + } +} + +func writeFileAtomically(path string, data []byte, perm os.FileMode) (err error) { + dir := filepath.Dir(path) + base := filepath.Base(path) + tmp, err := os.CreateTemp(dir, base+".tmp-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { + if err != nil { + _ = os.Remove(tmpPath) + } + }() + if _, err = tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err = tmp.Close(); err != nil { + return err + } + if err = os.Chmod(tmpPath, perm); err != nil { + return err + } + if err = os.Rename(tmpPath, path); err != nil { + return err + } + return nil +} diff --git a/internal/tuiperf/latency.go b/internal/tuiperf/latency.go new file mode 100644 index 00000000..bb342785 --- /dev/null +++ b/internal/tuiperf/latency.go @@ -0,0 +1,183 @@ +package tuiperf + +import ( + "bytes" + "context" + "fmt" + "io" + "sort" + "strings" + "time" +) + +type MarkerEvent struct { + Marker string + At time.Time +} + +func WatchMarkers(ctx context.Context, r io.Reader, markers []string, sink chan<- MarkerEvent) { + defer close(sink) + want := markerSet(markers) + if len(want) == 0 { + return + } + ctx = normalizeMarkerContext(ctx) + done := watchMarkerCancellation(ctx, r) + defer close(done) + maxMarkerLen := longestMarkerLength(want) + buffer := make([]byte, 0, maxMarkerLen*2) + chunk := make([]byte, 4096) + for { + n, err := r.Read(chunk) + updated, stop := processMarkerChunk(ctx, sink, buffer, chunk[:n], want, maxMarkerLen) + buffer = updated + if stop { + return + } + if err != nil { + return + } + if markerContextDone(ctx) { + return + } + } +} + +func markerSet(markers []string) map[string]struct{} { + want := map[string]struct{}{} + for _, marker := range markers { + trimmed := strings.TrimSpace(marker) + if trimmed == "" { + continue + } + want[trimmed] = struct{}{} + } + return want +} + +func longestMarkerLength(want map[string]struct{}) int { + longest := 0 + for marker := range want { + if len(marker) > longest { + longest = len(marker) + } + } + if longest == 0 { + return 1 + } + return longest +} + +func normalizeMarkerContext(ctx context.Context) context.Context { + if ctx == nil { + return context.Background() + } + return ctx +} + +func watchMarkerCancellation(ctx context.Context, r io.Reader) chan struct{} { + done := make(chan struct{}) + closer, ok := r.(io.ReadCloser) + if !ok { + return done + } + go func() { + select { + case <-ctx.Done(): + _ = closer.Close() + case <-done: + } + }() + return done +} + +func emitMarkerMatches(ctx context.Context, sink chan<- MarkerEvent, buffer []byte, want map[string]struct{}) ([]byte, bool) { + for { + marker, end, found := nextMarkerMatch(buffer, want) + if !found { + return buffer, false + } + select { + case sink <- MarkerEvent{Marker: marker, At: time.Now()}: + buffer = append([]byte(nil), buffer[end:]...) + case <-ctx.Done(): + return buffer, true + } + } +} + +func nextMarkerMatch(buffer []byte, want map[string]struct{}) (string, int, bool) { + bestIndex := -1 + bestEnd := -1 + bestMarker := "" + for marker := range want { + idx := bytes.Index(buffer, []byte(marker)) + if idx < 0 { + continue + } + if bestIndex == -1 || idx < bestIndex { + bestIndex = idx + bestEnd = idx + len(marker) + bestMarker = marker + } + } + if bestIndex == -1 { + return "", 0, false + } + return bestMarker, bestEnd, true +} + +func truncateMarkerBuffer(buffer []byte, maxMarkerLen int) []byte { + keep := maxMarkerLen - 1 + if keep < 1 { + keep = 1 + } + if len(buffer) <= keep { + return buffer + } + return append([]byte(nil), buffer[len(buffer)-keep:]...) +} + +func processMarkerChunk( + ctx context.Context, + sink chan<- MarkerEvent, + buffer []byte, + chunk []byte, + want map[string]struct{}, + maxMarkerLen int, +) ([]byte, bool) { + if len(chunk) == 0 { + return buffer, false + } + buffer = append(buffer, chunk...) + updated, stop := emitMarkerMatches(ctx, sink, buffer, want) + if stop { + return updated, true + } + return truncateMarkerBuffer(updated, maxMarkerLen), false +} + +func markerContextDone(ctx context.Context) bool { + select { + case <-ctx.Done(): + return true + default: + return false + } +} + +func P95Millis(samples []float64) (float64, error) { + if len(samples) == 0 { + return 0, fmt.Errorf("samples required") + } + vals := append([]float64(nil), samples...) + sort.Float64s(vals) + idx := int(float64(len(vals)-1) * 0.95) + if idx < 0 { + idx = 0 + } + if idx >= len(vals) { + idx = len(vals) - 1 + } + return vals[idx], nil +} diff --git a/internal/tuiperf/latency_test.go b/internal/tuiperf/latency_test.go new file mode 100644 index 00000000..f13c9667 --- /dev/null +++ b/internal/tuiperf/latency_test.go @@ -0,0 +1,134 @@ +package tuiperf + +import ( + "context" + "io" + "runtime" + "testing" + "time" +) + +func TestP95Millis(t *testing.T) { + v, err := P95Millis([]float64{10, 20, 30, 40, 50, 60, 70, 80, 90, 100}) + if err != nil { + t.Fatalf("P95Millis returned error: %v", err) + } + if v != 90 { + t.Fatalf("p95 = %.2f, want 90", v) + } +} + +func TestP95MillisRejectsEmpty(t *testing.T) { + if _, err := P95Millis(nil); err == nil { + t.Fatal("P95Millis error = nil, want error") + } +} + +func TestWatchMarkersClosesSinkWhenReaderEnds(t *testing.T) { + t.Parallel() + + r, w := io.Pipe() + events := make(chan MarkerEvent, 1) + go WatchMarkers(context.Background(), r, []string{"ready"}, events) + if _, err := io.WriteString(w, "ready\n"); err != nil { + t.Fatalf("WriteString error = %v", err) + } + _ = w.Close() + if _, ok := <-events; !ok { + t.Fatal("events closed before receiving marker") + } + if _, ok := <-events; ok { + t.Fatal("events channel still open, want closed") + } +} + +func TestWatchMarkersReturnsOnCancellation(t *testing.T) { + t.Parallel() + + r, _ := io.Pipe() + ctx, cancel := context.WithCancel(context.Background()) + events := make(chan MarkerEvent, 1) + go WatchMarkers(ctx, r, []string{"ready"}, events) + cancel() + select { + case _, ok := <-events: + if ok { + t.Fatal("events channel open after cancellation") + } + case <-time.After(500 * time.Millisecond): + t.Fatal("WatchMarkers did not stop after cancellation") + } +} + +func TestWatchMarkersCancellationHelperExitsAfterEOF(t *testing.T) { + t.Parallel() + baseline := runtime.NumGoroutine() + r, w := io.Pipe() + events := make(chan MarkerEvent, 1) + ctx, cancel := context.WithCancel(context.Background()) + go WatchMarkers(ctx, r, []string{"ready"}, events) + if _, err := io.WriteString(w, "ready\n"); err != nil { + t.Fatalf("WriteString error = %v", err) + } + _ = w.Close() + for range events { + } + for i := 0; i < 20; i++ { + if runtime.NumGoroutine() <= baseline+1 { + cancel() + return + } + time.Sleep(10 * time.Millisecond) + } + cancel() + t.Fatalf("goroutine count stayed elevated: baseline=%d current=%d", baseline, runtime.NumGoroutine()) +} + +func TestWatchMarkersDetectsMarkerWithoutTrailingNewline(t *testing.T) { + t.Parallel() + + r, w := io.Pipe() + events := make(chan MarkerEvent, 1) + go WatchMarkers(context.Background(), r, []string{"Runecode TUI α shell"}, events) + if _, err := io.WriteString(w, "prefix Runecode TUI α shell suffix"); err != nil { + t.Fatalf("WriteString error = %v", err) + } + _ = w.Close() + select { + case ev, ok := <-events: + if !ok { + t.Fatal("events closed before receiving marker") + } + if ev.Marker != "Runecode TUI α shell" { + t.Fatalf("marker = %q, want %q", ev.Marker, "Runecode TUI α shell") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for marker event") + } +} + +func TestWatchMarkersDetectsMarkerSplitAcrossWrites(t *testing.T) { + t.Parallel() + + r, w := io.Pipe() + events := make(chan MarkerEvent, 1) + go WatchMarkers(context.Background(), r, []string{"Runecode TUI α shell"}, events) + if _, err := io.WriteString(w, "Runecode TUI "); err != nil { + t.Fatalf("WriteString first chunk error = %v", err) + } + if _, err := io.WriteString(w, "α shell"); err != nil { + t.Fatalf("WriteString second chunk error = %v", err) + } + _ = w.Close() + select { + case ev, ok := <-events: + if !ok { + t.Fatal("events closed before receiving split marker") + } + if ev.Marker != "Runecode TUI α shell" { + t.Fatalf("marker = %q, want %q", ev.Marker, "Runecode TUI α shell") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for split marker event") + } +} diff --git a/internal/tuiperf/procstat.go b/internal/tuiperf/procstat.go new file mode 100644 index 00000000..69bb6423 --- /dev/null +++ b/internal/tuiperf/procstat.go @@ -0,0 +1,138 @@ +package tuiperf + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +type ProcStat struct { + PID int + Comm string + UserTicks uint64 + SystemTicks uint64 + StartTicks uint64 +} + +func (p ProcStat) TotalTicks() uint64 { + return p.UserTicks + p.SystemTicks +} + +func ReadProcStat(procRoot string, pid int) (ProcStat, error) { + raw, err := os.ReadFile(filepath.Join(procRoot, strconv.Itoa(pid), "stat")) + if err != nil { + return ProcStat{}, err + } + return ParseProcStatLine(strings.TrimSpace(string(raw))) +} + +func ParseProcStatLine(line string) (ProcStat, error) { + open := strings.Index(line, "(") + close := strings.LastIndex(line, ")") + if open <= 0 || close <= open { + return ProcStat{}, fmt.Errorf("invalid /proc stat format") + } + pid, err := strconv.Atoi(strings.TrimSpace(line[:open])) + if err != nil { + return ProcStat{}, fmt.Errorf("parse pid: %w", err) + } + comm := line[open+1 : close] + fields := strings.Fields(strings.TrimSpace(line[close+1:])) + if len(fields) < 20 { + return ProcStat{}, fmt.Errorf("invalid /proc stat field count") + } + userTicks, err := strconv.ParseUint(fields[11], 10, 64) + if err != nil { + return ProcStat{}, fmt.Errorf("parse utime: %w", err) + } + systemTicks, err := strconv.ParseUint(fields[12], 10, 64) + if err != nil { + return ProcStat{}, fmt.Errorf("parse stime: %w", err) + } + startTicks, err := strconv.ParseUint(fields[19], 10, 64) + if err != nil { + return ProcStat{}, fmt.Errorf("parse starttime: %w", err) + } + return ProcStat{PID: pid, Comm: comm, UserTicks: userTicks, SystemTicks: systemTicks, StartTicks: startTicks}, nil +} + +func FindDescendantByComm(procRoot string, rootPID int, comm string) (int, error) { + want := strings.TrimSpace(comm) + if want == "" { + return 0, fmt.Errorf("comm is required") + } + search := procTreeSearch{procRoot: procRoot, wantComm: want, queue: []int{rootPID}, seen: map[int]struct{}{rootPID: {}}} + for search.hasQueue() { + if pid, found := search.scanNext(); found { + return pid, nil + } + } + return 0, fmt.Errorf("descendant with comm %q not found", want) +} + +type procTreeSearch struct { + procRoot string + wantComm string + queue []int + seen map[int]struct{} +} + +func (s *procTreeSearch) hasQueue() bool { + return len(s.queue) > 0 +} + +func (s *procTreeSearch) popQueue() int { + pid := s.queue[0] + s.queue = s.queue[1:] + return pid +} + +func (s *procTreeSearch) scanNext() (int, bool) { + pid := s.popQueue() + children, err := readChildren(s.procRoot, pid) + if err != nil { + return 0, false + } + for _, child := range children { + if s.markSeen(child) { + continue + } + if s.childMatches(child) { + return child, true + } + s.queue = append(s.queue, child) + } + return 0, false +} + +func (s *procTreeSearch) markSeen(child int) bool { + if _, ok := s.seen[child]; ok { + return true + } + s.seen[child] = struct{}{} + return false +} + +func (s *procTreeSearch) childMatches(child int) bool { + childComm, err := os.ReadFile(filepath.Join(s.procRoot, strconv.Itoa(child), "comm")) + return err == nil && strings.TrimSpace(string(childComm)) == s.wantComm +} + +func readChildren(procRoot string, pid int) ([]int, error) { + raw, err := os.ReadFile(filepath.Join(procRoot, strconv.Itoa(pid), "task", strconv.Itoa(pid), "children")) + if err != nil { + return nil, err + } + parts := strings.Fields(string(raw)) + out := make([]int, 0, len(parts)) + for _, part := range parts { + id, err := strconv.Atoi(part) + if err != nil { + continue + } + out = append(out, id) + } + return out, nil +} diff --git a/internal/tuiperf/procstat_test.go b/internal/tuiperf/procstat_test.go new file mode 100644 index 00000000..115da965 --- /dev/null +++ b/internal/tuiperf/procstat_test.go @@ -0,0 +1,29 @@ +package tuiperf + +import "testing" + +func TestParseProcStatLine(t *testing.T) { + line := "1234 (runecode-tui) S 1 2 3 4 5 6 7 8 9 10 120 30 0 0 20 0 1 0 999 0 0 0" + stat, err := ParseProcStatLine(line) + if err != nil { + t.Fatalf("ParseProcStatLine returned error: %v", err) + } + if stat.PID != 1234 { + t.Fatalf("PID = %d, want 1234", stat.PID) + } + if stat.Comm != "runecode-tui" { + t.Fatalf("Comm = %q, want runecode-tui", stat.Comm) + } + if stat.UserTicks != 120 || stat.SystemTicks != 30 { + t.Fatalf("ticks = (%d,%d), want (120,30)", stat.UserTicks, stat.SystemTicks) + } + if stat.StartTicks != 999 { + t.Fatalf("StartTicks = %d, want 999", stat.StartTicks) + } +} + +func TestParseProcStatLineRejectsInvalid(t *testing.T) { + if _, err := ParseProcStatLine("bad"); err == nil { + t.Fatal("ParseProcStatLine error = nil, want error") + } +} diff --git a/justfile b/justfile index b8e0e6e0..46db8e0d 100644 --- a/justfile +++ b/justfile @@ -15,33 +15,51 @@ lint: cd runner && npm run boundary-check test: + cd runner && npm ci go test ./... cd runner && npm test model-check: - go run ./tools/tlccheck + go run ./tools/tlccheck --mode all -ci: +model-check-core: + go run ./tools/tlccheck --mode core + +model-check-replay: + go run ./tools/tlccheck --mode replay + +ci-fast: go run ./tools/gofmtcheck go run {{golangci_lint}} run go vet ./... go run ./tools/checksourcequality - just model-check + cd runner && npm ci go test ./... go build ./cmd/... - cd runner && npm ci cd runner && npm run lint cd runner && npm test cd runner && npm run boundary-check +ci: + # Canonical local check entrypoint. + # Required shared-Linux performance contracts run in CI via ci-required-shared-linux. + just ci-fast + just model-check + +ci-required-shared-linux: + just ci-fast + tmpdir="$(mktemp -d)" && trap 'rm -rf "$tmpdir"' EXIT && \ + go run ./tools/perfgatesharedlinux --output "$tmpdir/perf-check.json" && \ + go run ./tools/perfcontracts --check-output "$tmpdir/perf-check.json" --lane required_shared_linux + ci-portability: go run ./tools/gofmtcheck go run {{golangci_lint}} run go vet ./... go run ./tools/checksourcequality + cd runner && npm ci go test ./... go build ./cmd/... - cd runner && npm ci cd runner && npm run lint cd runner && npm test cd runner && npm run boundary-check diff --git a/nix/packages/release-artifacts.nix b/nix/packages/release-artifacts.nix index f4b5a990..f50947a2 100644 --- a/nix/packages/release-artifacts.nix +++ b/nix/packages/release-artifacts.nix @@ -75,7 +75,7 @@ pkgs.buildGoModule { src = releaseSource; go = goToolchain; # Refresh explicitly with `just refresh-release-vendor-hash`. - vendorHash = "sha256-X7jALliWzq3PLI1eeluKi8rHxqhjGyiU+WmU5GsqiFs="; + vendorHash = "sha256-I+WLce2YQRAVkMovlaWcbocashO88gyF9P4/y4dHJho="; # The workflow runs `just ci` before building this packaging-focused derivation. doCheck = false; strictDeps = true; diff --git a/nix/release/metadata.nix b/nix/release/metadata.nix index 85a766fd..954c197e 100644 --- a/nix/release/metadata.nix +++ b/nix/release/metadata.nix @@ -1,7 +1,7 @@ let base = { packageName = "runecode"; - version = "0.1.0-alpha.10"; + version = "0.1.0-alpha.11"; binaries = [ "runecode" diff --git a/protocol/fixtures/manifest.json b/protocol/fixtures/manifest.json index e2a442ed..f7b01269 100644 --- a/protocol/fixtures/manifest.json +++ b/protocol/fixtures/manifest.json @@ -529,9 +529,9 @@ "expect_valid": false }, { - "id": "runecontext-approved-implementation-input-set.valid-minimal", + "id": "runecontext-approved-implementation-input-set.valid-populated", "schema_path": "objects/RuneContextApprovedImplementationInputSet.schema.json", - "fixture_path": "schema/runecontext-approved-implementation-input-set.valid-minimal.json", + "fixture_path": "schema/runecontext-approved-implementation-input-set.valid-populated.json", "expect_valid": true }, { diff --git a/protocol/fixtures/schema/run-plan.valid-minimal.json b/protocol/fixtures/schema/run-plan.valid-minimal.json index e8a60534..c963de5f 100644 --- a/protocol/fixtures/schema/run-plan.valid-minimal.json +++ b/protocol/fixtures/schema/run-plan.valid-minimal.json @@ -2,14 +2,12 @@ "schema_id": "runecode.protocol.v0.RunPlan", "schema_version": "0.4.0", "plan_id": "plan_run_123_0001", - "supersedes_plan_id": "plan_run_123_0000", "run_id": "run_123", "workflow_id": "workflow_main", "workflow_version": "1.0.0", "process_id": "process_default", "approval_profile": "moderate", "autonomy_posture": "balanced", - "policy_binding_id": "policy_binding_default", "workflow_definition_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "process_definition_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "policy_context_hash": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", @@ -23,8 +21,7 @@ "executor_id": "workspace-runner", "executor_class": "workspace_ordinary", "allowed_role_kinds": [ - "workspace-edit", - "workspace-test" + "workspace-edit" ] } ], @@ -38,25 +35,13 @@ "step_id": "validation_build", "role_instance_id": "workspace_editor_1", "executor_binding_id": "binding_workspace_runner", - "dependency_cache_handoffs": [ - { - "request_digest": { "hash_alg": "sha256", "hash": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" }, - "consumer_role": "workspace", - "required": true - } - ], "gate": { "schema_id": "runecode.protocol.v0.GateContract", "schema_version": "0.1.0", "gate_id": "build_gate", "gate_kind": "build", "gate_version": "1.0.0", - "normalized_inputs": [ - { - "input_id": "source_tree", - "input_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111" - } - ], + "normalized_inputs": [], "plan_binding": { "checkpoint_code": "step_validation_started", "order_index": 0 @@ -90,12 +75,7 @@ "gate_id": "build_gate", "gate_kind": "build", "gate_version": "1.0.0", - "normalized_inputs": [ - { - "input_id": "source_tree", - "input_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111" - } - ], + "normalized_inputs": [], "plan_binding": { "checkpoint_code": "step_validation_started", "order_index": 0 @@ -110,16 +90,6 @@ "approval_trigger_code": "gate_override" } }, - "dependency_cache_handoffs": [ - { - "request_digest": { - "hash_alg": "sha256", - "hash": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" - }, - "consumer_role": "workspace", - "required": true - } - ], "depends_on_entry_ids": [], "blocks_entry_ids": [], "supported_wait_kinds": [ diff --git a/protocol/fixtures/schema/runecontext-approved-implementation-input-set.valid-minimal.json b/protocol/fixtures/schema/runecontext-approved-implementation-input-set.valid-populated.json similarity index 77% rename from protocol/fixtures/schema/runecontext-approved-implementation-input-set.valid-minimal.json rename to protocol/fixtures/schema/runecontext-approved-implementation-input-set.valid-populated.json index 2ced0a4b..4d8fb50d 100644 --- a/protocol/fixtures/schema/runecontext-approved-implementation-input-set.valid-minimal.json +++ b/protocol/fixtures/schema/runecontext-approved-implementation-input-set.valid-populated.json @@ -3,7 +3,7 @@ "schema_version": "0.1.0", "input_set_digest": { "hash_alg": "sha256", - "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + "hash": "194c10fbb20693642c2af7fbd8ced95760faaf20fbde7a6d3eeb4281abd67e44" }, "approved_input_digests": [ { @@ -11,6 +11,18 @@ "hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } ], + "workspace_mutation_digests": [ + { + "hash_alg": "sha256", + "hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + ], + "lifecycle_metadata_mutation_digests": [ + { + "hash_alg": "sha256", + "hash": "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1" + } + ], "workflow_definition_hash": { "hash_alg": "sha256", "hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" diff --git a/protocol/schemas/objects/RuneContextApprovedImplementationInputSet.schema.json b/protocol/schemas/objects/RuneContextApprovedImplementationInputSet.schema.json index 3938d63d..603555e0 100644 --- a/protocol/schemas/objects/RuneContextApprovedImplementationInputSet.schema.json +++ b/protocol/schemas/objects/RuneContextApprovedImplementationInputSet.schema.json @@ -2,10 +2,10 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://runecode.dev/protocol/schemas/objects/RuneContextApprovedImplementationInputSet.schema.json", "title": "RuneContextApprovedImplementationInputSet", - "description": "Typed approved implementation input-set contract for fail-closed binding and drift checks in the built-in RuneContext implementation workflow.", + "description": "Typed approved implementation input-set contract for fail-closed binding and drift checks in the built-in RuneContext implementation workflow. Workflow routing binds a separate artifact digest to the exact stored canonical JSON artifact bytes, while input_set_digest is the self-excluded semantic digest recomputed from the canonical input-set body with input_set_digest omitted.", "type": "object", "additionalProperties": false, - "maxProperties": 13, + "maxProperties": 15, "required": [ "schema_id", "schema_version", @@ -34,7 +34,7 @@ }, "input_set_digest": { "$ref": "Digest.schema.json#/$defs/digestValue", - "description": "Canonical digest identity of the whole approved implementation input-set payload.", + "description": "Canonical semantic digest identity of the approved implementation input-set body after omitting input_set_digest itself. This is distinct from the workflow-routing bound artifact digest, which identifies the exact stored canonical JSON artifact bytes.", "x-data-class": "public" }, "approved_input_digests": { @@ -49,6 +49,30 @@ "x-data-class": "public" } }, + "workspace_mutation_digests": { + "type": "array", + "minItems": 1, + "maxItems": 128, + "uniqueItems": true, + "description": "Optional exact digest list for approved local workspace mutation inputs executed by this run.", + "x-data-class": "public", + "items": { + "$ref": "Digest.schema.json#/$defs/digestValue", + "x-data-class": "public" + } + }, + "lifecycle_metadata_mutation_digests": { + "type": "array", + "minItems": 1, + "maxItems": 128, + "uniqueItems": true, + "description": "Optional exact digest list for approved RuneContext lifecycle metadata mutation inputs executed by this run.", + "x-data-class": "public", + "items": { + "$ref": "Digest.schema.json#/$defs/digestValue", + "x-data-class": "public" + } + }, "workflow_definition_hash": { "$ref": "Digest.schema.json#/$defs/digestValue", "description": "Canonical digest identity of the reviewed workflow definition bound at approval time.", diff --git a/runecontext/changes/CHG-2026-002-33c5-git-gateway-commit-push-pr/status.yaml b/runecontext/changes/CHG-2026-002-33c5-git-gateway-commit-push-pr/status.yaml index f78fcebd..850e1169 100644 --- a/runecontext/changes/CHG-2026-002-33c5-git-gateway-commit-push-pr/status.yaml +++ b/runecontext/changes/CHG-2026-002-33c5-git-gateway-commit-push-pr/status.yaml @@ -16,6 +16,7 @@ related_decisions: related_changes: - CHG-2026-049-1d4e-first-party-runecontext-workflow-pack-v0 - CHG-2026-050-e3f8-workflow-definition-contract-binding-v0 + - CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0 - CHG-2026-003-b567-audit-log-v0-verify - CHG-2026-004-acdb-artifact-store-data-classes-v0 - CHG-2026-007-2315-policy-engine-v0 diff --git a/runecontext/changes/CHG-2026-015-cae6-formal-spec-v0-tla-ci-model-checking/design.md b/runecontext/changes/CHG-2026-015-cae6-formal-spec-v0-tla-ci-model-checking/design.md index 16546747..2e166501 100644 --- a/runecontext/changes/CHG-2026-015-cae6-formal-spec-v0-tla-ci-model-checking/design.md +++ b/runecontext/changes/CHG-2026-015-cae6-formal-spec-v0-tla-ci-model-checking/design.md @@ -187,6 +187,7 @@ The first formal model should at least prove: - Add checked-in TLA+ spec files and deterministic TLC configs. - Add a dedicated `just` recipe for model checking and include it in `just ci`. - Add the required tooling to the dev shell and CI environment explicitly; the model-checking path must be deterministic and must leave the repo clean. +- Keep PR CI security-preserving but bounded: run the core model for security-kernel-relevant code or protocol PR diffs, run the full model for formal-spec/tooling/workflow PR diffs, and run the full model on merge queue and `main` so the final merge candidate remains covered without replaying the slow model on every push. - Keep bounds small but meaningful: - multiple runs - multiple approvals diff --git a/runecontext/changes/CHG-2026-015-cae6-formal-spec-v0-tla-ci-model-checking/tasks.md b/runecontext/changes/CHG-2026-015-cae6-formal-spec-v0-tla-ci-model-checking/tasks.md index 194cefc1..20b7d4bf 100644 --- a/runecontext/changes/CHG-2026-015-cae6-formal-spec-v0-tla-ci-model-checking/tasks.md +++ b/runecontext/changes/CHG-2026-015-cae6-formal-spec-v0-tla-ci-model-checking/tasks.md @@ -80,6 +80,7 @@ Parallelization: can proceed once the semantic freeze above is settled; keep the - [x] Add a dedicated `just` recipe for model checking. - [x] Add the required TLA+ and TLC tooling to the dev shell and CI environment explicitly. - [x] Run model checking in `just ci` and fail closed on invariant violations. +- [x] Split CI scheduling so security-kernel-relevant PR diffs run the faster core model, while merge queue and `main` run the full model. - [x] Keep the repo clean and deterministic after local or CI model checking. Parallelization: can be implemented in parallel with spec authoring once the toolchain and CI ownership are agreed. diff --git a/runecontext/changes/CHG-2026-024-acde-deps-fetch-offline-cache/status.yaml b/runecontext/changes/CHG-2026-024-acde-deps-fetch-offline-cache/status.yaml index c1a0cae5..e946f977 100644 --- a/runecontext/changes/CHG-2026-024-acde-deps-fetch-offline-cache/status.yaml +++ b/runecontext/changes/CHG-2026-024-acde-deps-fetch-offline-cache/status.yaml @@ -7,12 +7,12 @@ size: medium verification_status: pending context_bundles: - product-planning +related_specs: [] related_decisions: - decisions/DEC-2026-001-runecontext-canonical-planning-system.md - decisions/DEC-2026-002-verified-assurance-and-assurance-path.md - decisions/DEC-2026-003-bundled-runecontext-default-and-verified-requirement.md - decisions/DEC-2026-004-runecode-ux-ownership-and-runecontext-generic-boundary.md -related_specs: [] related_changes: - CHG-2026-049-1d4e-first-party-runecontext-workflow-pack-v0 - CHG-2026-050-e3f8-workflow-definition-contract-binding-v0 diff --git a/runecontext/changes/CHG-2026-025-5679-external-audit-anchoring-v0/status.yaml b/runecontext/changes/CHG-2026-025-5679-external-audit-anchoring-v0/status.yaml index 4065b76f..231f9b8f 100644 --- a/runecontext/changes/CHG-2026-025-5679-external-audit-anchoring-v0/status.yaml +++ b/runecontext/changes/CHG-2026-025-5679-external-audit-anchoring-v0/status.yaml @@ -1,23 +1,24 @@ schema_version: 1 id: CHG-2026-025-5679-external-audit-anchoring-v0 title: External Audit Anchoring v0 -status: planned +status: implemented type: feature size: medium verification_status: pending context_bundles: - product-planning +related_specs: [] related_decisions: - decisions/DEC-2026-001-runecontext-canonical-planning-system.md - decisions/DEC-2026-002-verified-assurance-and-assurance-path.md - decisions/DEC-2026-003-bundled-runecontext-default-and-verified-requirement.md - decisions/DEC-2026-004-runecode-ux-ownership-and-runecontext-generic-boundary.md -related_specs: [] related_changes: - CHG-2026-046-a91d-runecontext-verified-project-substrate-compatibility-lifecycle-v0 - CHG-2026-006-84f0-audit-anchoring-v0 - CHG-2026-003-b567-audit-log-v0-verify - CHG-2026-007-2315-policy-engine-v0 + - CHG-2026-053-9d2b-performance-baselines-verification-gates-v0 depends_on: [] informed_by: [] supersedes: [] diff --git a/runecontext/changes/CHG-2026-026-98be-image-toolchain-signing-pipeline/status.yaml b/runecontext/changes/CHG-2026-026-98be-image-toolchain-signing-pipeline/status.yaml index 1e369a41..b2e4fd84 100644 --- a/runecontext/changes/CHG-2026-026-98be-image-toolchain-signing-pipeline/status.yaml +++ b/runecontext/changes/CHG-2026-026-98be-image-toolchain-signing-pipeline/status.yaml @@ -1,18 +1,18 @@ schema_version: 1 id: CHG-2026-026-98be-image-toolchain-signing-pipeline title: Image/Toolchain Signing Pipeline -status: planned +status: implemented type: feature size: large verification_status: pending context_bundles: - product-planning +related_specs: [] related_decisions: - decisions/DEC-2026-001-runecontext-canonical-planning-system.md - decisions/DEC-2026-002-verified-assurance-and-assurance-path.md - decisions/DEC-2026-003-bundled-runecontext-default-and-verified-requirement.md - decisions/DEC-2026-004-runecode-ux-ownership-and-runecontext-generic-boundary.md -related_specs: [] related_changes: - CHG-2026-046-a91d-runecontext-verified-project-substrate-compatibility-lifecycle-v0 - CHG-2026-009-1672-launcher-microvm-backend-v0 diff --git a/runecontext/changes/CHG-2026-028-647e-windows-microvm-runtime-support/status.yaml b/runecontext/changes/CHG-2026-028-647e-windows-microvm-runtime-support/status.yaml index 84d44eb0..18059539 100644 --- a/runecontext/changes/CHG-2026-028-647e-windows-microvm-runtime-support/status.yaml +++ b/runecontext/changes/CHG-2026-028-647e-windows-microvm-runtime-support/status.yaml @@ -16,6 +16,7 @@ related_decisions: related_changes: - CHG-2026-009-1672-launcher-microvm-backend-v0 - CHG-2026-021-8d6d-local-ipc-protobuf-transport-v0 + - CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0 depends_on: [] informed_by: [] supersedes: [] diff --git a/runecontext/changes/CHG-2026-029-5e5e-macos-virtualization-polish/status.yaml b/runecontext/changes/CHG-2026-029-5e5e-macos-virtualization-polish/status.yaml index d815588b..89f651fd 100644 --- a/runecontext/changes/CHG-2026-029-5e5e-macos-virtualization-polish/status.yaml +++ b/runecontext/changes/CHG-2026-029-5e5e-macos-virtualization-polish/status.yaml @@ -16,6 +16,7 @@ related_specs: [] related_changes: - CHG-2026-009-1672-launcher-microvm-backend-v0 - CHG-2026-010-54b7-container-backend-v0-explicit-opt-in + - CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0 depends_on: [] informed_by: [] supersedes: [] diff --git a/runecontext/changes/CHG-2026-030-98b8-isolate-attestation-v0/status.yaml b/runecontext/changes/CHG-2026-030-98b8-isolate-attestation-v0/status.yaml index 15fee792..a98517f8 100644 --- a/runecontext/changes/CHG-2026-030-98b8-isolate-attestation-v0/status.yaml +++ b/runecontext/changes/CHG-2026-030-98b8-isolate-attestation-v0/status.yaml @@ -1,7 +1,7 @@ schema_version: 1 id: CHG-2026-030-98b8-isolate-attestation-v0 title: Isolate Attestation v0 -status: planned +status: implemented type: feature size: medium verification_status: pending diff --git a/runecontext/changes/CHG-2026-044-9f2a-optional-langgraph-runner-runtime-evaluation/design.md b/runecontext/changes/CHG-2026-044-9f2a-optional-langgraph-runner-runtime-evaluation/design.md index ddb6a96f..3d442e27 100644 --- a/runecontext/changes/CHG-2026-044-9f2a-optional-langgraph-runner-runtime-evaluation/design.md +++ b/runecontext/changes/CHG-2026-044-9f2a-optional-langgraph-runner-runtime-evaluation/design.md @@ -5,7 +5,7 @@ Evaluate whether LangGraph provides enough implementation leverage for runner-lo ## Key Decisions - LangGraph remains optional; implementation should be decided at delivery time based on whether the native runner foundation still leaves enough orchestration complexity to justify it. -- Native thin-kernel runner hardening remains the prerequisite and baseline. +- Native thin-kernel runner hardening remains the prerequisite and baseline, including CHG-060's real broker transport, persisted `RunPlan` adoption, runner checkpoint/result reporting, and supported beta workflow loop. - Any LangGraph usage must stay behind the internal runtime seam established by `CHG-2026-033-6e7b-workflow-runner-durable-state-v0`. - LangGraph must remain internal and non-canonical. - Broker-owned run truth, approval truth, lifecycle state, and immutable `RunPlan` authority remain unchanged. @@ -23,6 +23,7 @@ Evaluate whether LangGraph provides enough implementation leverage for runner-lo LangGraph should be implemented only if all of the following are true at that time: - the native runner durable-state and approval-wait model is already complete and verified +- CHG-060 has already proven the supported beta workflow loop through the native runner path - the runtime seam is in place and small enough to keep LangGraph fully internal - LangGraph measurably reduces runner-local orchestration complexity for pause/wait/resume flows - replay, interrupt, and checkpoint semantics can be bound cleanly to the same `run_id`, `plan_id`, scope identity, attempt identity, and idempotency model RuneCode already uses @@ -40,6 +41,7 @@ LangGraph adoption must not: - define a second public lifecycle vocabulary - require broker/API contracts to mirror LangGraph thread/checkpoint vocabulary - replace explicit runner journal families with opaque framework-owned blobs +- replace or shortcut the CHG-060 beta workflow-loop proof - weaken exact-action approval or remote-drift semantics for git remote mutation or other hard-floor remote-state-mutation lanes - weaken exact-action approval, target binding, or deferred prepared and execute semantics for external audit anchor submission or other hard-floor remote-state-mutation lanes diff --git a/runecontext/changes/CHG-2026-044-9f2a-optional-langgraph-runner-runtime-evaluation/proposal.md b/runecontext/changes/CHG-2026-044-9f2a-optional-langgraph-runner-runtime-evaluation/proposal.md index 64a8b472..26b57e74 100644 --- a/runecontext/changes/CHG-2026-044-9f2a-optional-langgraph-runner-runtime-evaluation/proposal.md +++ b/runecontext/changes/CHG-2026-044-9f2a-optional-langgraph-runner-runtime-evaluation/proposal.md @@ -1,11 +1,11 @@ ## Summary -RuneCode may optionally evaluate LangGraph as an internal runner runtime for local checkpoint, wait, and resume mechanics after the native thin-kernel runner foundation is complete, but only if it is still needed and without changing trust boundaries or canonical broker-owned contracts. +RuneCode may optionally evaluate LangGraph as an internal runner runtime for local checkpoint, wait, and resume mechanics after the native thin-kernel runner foundation and CHG-060 beta workflow loop are complete, but only if it is still needed and without changing trust boundaries or canonical broker-owned contracts. ## Problem RuneCode needs durable stop, wait, persist, and resume behavior for approvals and user input across process restarts. LangGraph provides generic persistence and interrupt primitives, but adopting it too early risks coupling the runner to a third-party thread/checkpoint model before RuneCode's own plan-bound recovery, approval, and broker-reconciliation semantics are fully hardened. ## Proposed Change -- Reassess whether LangGraph is needed after the native runner durable-state and approval-wait foundation is complete. +- Reassess whether LangGraph is needed after the native runner durable-state and approval-wait foundation is complete and after CHG-060 proves real broker transport, persisted `RunPlan` adoption, runner checkpoint/result reporting, and the supported beta workflow loop. - If still useful, evaluate LangGraph only as an internal runtime implementation behind the runner runtime seam. - Keep broker-owned run truth, approval truth, lifecycle state, and CHG-050 immutable `RunPlan` runtime authority canonical. - Keep LangGraph checkpoints, threads, and interrupt state non-canonical and outside the trust root unless exported through existing typed protocol objects. @@ -21,9 +21,11 @@ This work belongs on the roadmap as an explicit optional post-MVP follow-on so t - `runecontext/changes/*` is the canonical planning surface for this repository. - RuneCode keeps the end-user command surface while using bundled RuneContext capabilities under the hood where project context or assurance is involved. - Context-aware delivery for this feature is planned directly against verified-mode RuneContext rather than a later retrofit from legacy Agent OS semantics. +- CHG-060 is the concrete native runner/product-loop hardening checkpoint that must land before this optional evaluation can decide whether LangGraph still buys enough implementation leverage. ## Out of Scope - Making LangGraph mandatory for MVP or alpha runner delivery. +- Using LangGraph to replace or shortcut CHG-060's native beta workflow-loop proof. - Letting LangGraph become the source of planning, approval truth, or operator-facing lifecycle state. - Changing the broker local API, protocol schema families, or trust-boundary ownership model just to match LangGraph internals. - Letting LangGraph redefine or soften exact-action approval semantics for git remote mutation or other hard-floor remote-state-mutation actions. diff --git a/runecontext/changes/CHG-2026-044-9f2a-optional-langgraph-runner-runtime-evaluation/tasks.md b/runecontext/changes/CHG-2026-044-9f2a-optional-langgraph-runner-runtime-evaluation/tasks.md index d0ffa2aa..df13d477 100644 --- a/runecontext/changes/CHG-2026-044-9f2a-optional-langgraph-runner-runtime-evaluation/tasks.md +++ b/runecontext/changes/CHG-2026-044-9f2a-optional-langgraph-runner-runtime-evaluation/tasks.md @@ -3,6 +3,7 @@ ## Adoption Gate - [ ] Reassess the runner after `CHG-2026-033-6e7b-workflow-runner-durable-state-v0` native hardening is complete. +- [ ] Reassess the runner only after CHG-060 proves real broker transport, persisted `RunPlan` adoption, runner checkpoint/result reporting, and the supported beta workflow loop. - [ ] Decide whether LangGraph is still needed for runner-local checkpoint/wait/resume complexity. - [ ] Record the outcome explicitly: adopt behind the runtime seam or do not adopt. - [ ] Require the adoption decision to account for exact-action wait support for hard-floor approvals such as `git_remote_ops`. @@ -11,6 +12,7 @@ ## Runtime Seam Fit - [ ] Confirm the runner runtime seam is narrow enough to keep LangGraph fully internal. +- [ ] Confirm LangGraph is not being used to replace or shortcut the CHG-060 beta workflow-loop proof. - [ ] Ensure LangGraph can be substituted without changing broker local API contracts, protocol schemas, or broker-owned lifecycle/approval semantics. - [ ] Ensure LangGraph does not require relaxing exact-action approval or remote-drift semantics for `git_remote_ops` or similar hard-floor remote-state-mutation lanes. - [ ] Ensure LangGraph does not require relaxing exact-action approval, target-binding, or deferred prepared and execute semantics for external audit anchor submission or similar hard-floor remote-state-mutation lanes. @@ -38,6 +40,7 @@ - [ ] LangGraph is implemented only if it remains optional, internal-only, and clearly beneficial. - [ ] Adoption, if chosen, does not change trust-boundary ownership, broker authority, or public contracts. +- [ ] Adoption, if chosen, remains downstream of CHG-060 and does not redefine the supported beta workflow-loop architecture. - [ ] Replay, wait/resume, and restart semantics remain fail-closed and plan-bound. - [ ] Adoption, if chosen, does not weaken exact-action approval or fail-closed remote-drift handling for `git_remote_ops` or similar hard-floor remote-state-mutation lanes. - [ ] Adoption, if chosen, does not weaken exact-action approval, target binding, deferred execution semantics, or fail-closed drift handling for external audit anchor submission or similar hard-floor remote-state-mutation lanes. diff --git a/runecontext/changes/CHG-2026-044-9f2a-optional-langgraph-runner-runtime-evaluation/verification.md b/runecontext/changes/CHG-2026-044-9f2a-optional-langgraph-runner-runtime-evaluation/verification.md index 99fea8cf..d0370ff1 100644 --- a/runecontext/changes/CHG-2026-044-9f2a-optional-langgraph-runner-runtime-evaluation/verification.md +++ b/runecontext/changes/CHG-2026-044-9f2a-optional-langgraph-runner-runtime-evaluation/verification.md @@ -7,6 +7,7 @@ ## Verification Notes - Confirm the roadmap and change text both describe LangGraph as optional and post-MVP rather than required. +- Confirm the change treats CHG-060's native beta workflow-loop proof as a prerequisite rather than something LangGraph may replace or shortcut. - Confirm the change explicitly states that implementation should be determined later based on whether it is still needed. - Confirm the change keeps LangGraph internal-only and non-canonical. - Confirm the change preserves broker-owned run truth, approval truth, lifecycle state, and immutable `RunPlan` authority. diff --git a/runecontext/changes/CHG-2026-045-7f4c-direct-credential-model-providers-v0/status.yaml b/runecontext/changes/CHG-2026-045-7f4c-direct-credential-model-providers-v0/status.yaml index 34caf68d..3fb87389 100644 --- a/runecontext/changes/CHG-2026-045-7f4c-direct-credential-model-providers-v0/status.yaml +++ b/runecontext/changes/CHG-2026-045-7f4c-direct-credential-model-providers-v0/status.yaml @@ -22,6 +22,7 @@ related_changes: - CHG-2026-020-4425-openai-chatgpt-subscription-provider-oauth-codex-bridge - CHG-2026-022-8051-github-copilot-subscription-provider-official-runtime-bridge - CHG-2026-047-c3e2-local-control-plane-bootstrap-persistent-session-lifecycle-v0 + - CHG-2026-060-c1a4-beta-readiness-hardening-product-polish depends_on: - CHG-2026-031-7a3c-secretsd-core-v0 - CHG-2026-032-4d1f-model-gateway-v0 diff --git a/runecontext/changes/CHG-2026-046-a91d-runecontext-verified-project-substrate-compatibility-lifecycle-v0/status.yaml b/runecontext/changes/CHG-2026-046-a91d-runecontext-verified-project-substrate-compatibility-lifecycle-v0/status.yaml index 38486571..30145ce1 100644 --- a/runecontext/changes/CHG-2026-046-a91d-runecontext-verified-project-substrate-compatibility-lifecycle-v0/status.yaml +++ b/runecontext/changes/CHG-2026-046-a91d-runecontext-verified-project-substrate-compatibility-lifecycle-v0/status.yaml @@ -28,6 +28,7 @@ related_changes: - CHG-2026-049-1d4e-first-party-runecontext-workflow-pack-v0 - CHG-2026-050-e3f8-workflow-definition-contract-binding-v0 - CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0 + - CHG-2026-060-c1a4-beta-readiness-hardening-product-polish depends_on: [] informed_by: - CHG-2026-001-57d6-agent-os-to-runecontext-migration-umbrella diff --git a/runecontext/changes/CHG-2026-047-c3e2-local-control-plane-bootstrap-persistent-session-lifecycle-v0/status.yaml b/runecontext/changes/CHG-2026-047-c3e2-local-control-plane-bootstrap-persistent-session-lifecycle-v0/status.yaml index 2a9b999e..9d6277ce 100644 --- a/runecontext/changes/CHG-2026-047-c3e2-local-control-plane-bootstrap-persistent-session-lifecycle-v0/status.yaml +++ b/runecontext/changes/CHG-2026-047-c3e2-local-control-plane-bootstrap-persistent-session-lifecycle-v0/status.yaml @@ -23,6 +23,7 @@ related_changes: - CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0 - CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0 - CHG-2026-059-7b31-cross-machine-evidence-replication-restore-v0 + - CHG-2026-060-c1a4-beta-readiness-hardening-product-polish depends_on: - CHG-2026-008-62e1-broker-local-api-v0 - CHG-2026-013-d2c9-minimal-tui-v0 diff --git a/runecontext/changes/CHG-2026-048-6b7a-session-execution-orchestration-v0/status.yaml b/runecontext/changes/CHG-2026-048-6b7a-session-execution-orchestration-v0/status.yaml index 4ab4dd0a..6aae202c 100644 --- a/runecontext/changes/CHG-2026-048-6b7a-session-execution-orchestration-v0/status.yaml +++ b/runecontext/changes/CHG-2026-048-6b7a-session-execution-orchestration-v0/status.yaml @@ -20,6 +20,7 @@ related_changes: - CHG-2026-047-c3e2-local-control-plane-bootstrap-persistent-session-lifecycle-v0 - CHG-2026-049-1d4e-first-party-runecontext-workflow-pack-v0 - CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0 + - CHG-2026-060-c1a4-beta-readiness-hardening-product-polish depends_on: - CHG-2026-012-f1ef-workflow-runner-workspace-roles-deterministic-gates-v0 - CHG-2026-040-2b7f-session-transcript-model-v0 diff --git a/runecontext/changes/CHG-2026-049-1d4e-first-party-runecontext-workflow-pack-v0/status.yaml b/runecontext/changes/CHG-2026-049-1d4e-first-party-runecontext-workflow-pack-v0/status.yaml index 34833c4a..ecd58d2a 100644 --- a/runecontext/changes/CHG-2026-049-1d4e-first-party-runecontext-workflow-pack-v0/status.yaml +++ b/runecontext/changes/CHG-2026-049-1d4e-first-party-runecontext-workflow-pack-v0/status.yaml @@ -1,7 +1,7 @@ schema_version: 1 id: CHG-2026-049-1d4e-first-party-runecontext-workflow-pack-v0 title: First-Party RuneContext Workflow Pack v0 -status: planned +status: implemented type: feature size: large verification_status: pending @@ -19,6 +19,8 @@ related_changes: - CHG-2026-048-6b7a-session-execution-orchestration-v0 - CHG-2026-050-e3f8-workflow-definition-contract-binding-v0 - CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0 + - CHG-2026-060-c1a4-beta-readiness-hardening-product-polish + - CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0 depends_on: - CHG-2026-024-acde-deps-fetch-offline-cache - CHG-2026-046-a91d-runecontext-verified-project-substrate-compatibility-lifecycle-v0 diff --git a/runecontext/changes/CHG-2026-050-e3f8-workflow-definition-contract-binding-v0/status.yaml b/runecontext/changes/CHG-2026-050-e3f8-workflow-definition-contract-binding-v0/status.yaml index 5b211ead..4b9897d0 100644 --- a/runecontext/changes/CHG-2026-050-e3f8-workflow-definition-contract-binding-v0/status.yaml +++ b/runecontext/changes/CHG-2026-050-e3f8-workflow-definition-contract-binding-v0/status.yaml @@ -19,6 +19,7 @@ related_changes: - CHG-2026-024-acde-deps-fetch-offline-cache - CHG-2026-049-1d4e-first-party-runecontext-workflow-pack-v0 - CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0 + - CHG-2026-060-c1a4-beta-readiness-hardening-product-polish depends_on: - CHG-2026-007-2315-policy-engine-v0 - CHG-2026-008-62e1-broker-local-api-v0 diff --git a/runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/design.md b/runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/design.md index 6a88e877..c92ada2a 100644 --- a/runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/design.md +++ b/runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/design.md @@ -10,6 +10,7 @@ Define a broker-owned model for decomposing implementation work into low-couplin - Inferred track grouping must become a broker-owned proposed execution-plan artifact rather than a hidden heuristic. - Git worktrees are the preferred isolation substrate for low-coupling parallel implementation tracks, but they are not mandatory for every implementation plan. - Worktree execution should remain fail closed: if overlap risk, dependency ambiguity, or project-context drift makes safe parallelization unclear, RuneCode should pause for operator input or fall back to a more conservative execution mode. +- `CHG-2026-060-c1a4-beta-readiness-hardening-product-polish` now owns the first approved-change implementation proof through the real product path; this change extends that single-lane baseline rather than defining it. - Pending operator input or formal approval should block only the directly affected track and direct downstream dependent tracks; unrelated eligible tracks may continue only when the active plan, dependency graph, policy, coordination state, and project-substrate posture allow it. - Multiple pending waits may coexist simultaneously; resolution of one wait resumes only the affected track(s) and newly unblocked dependents. - Track execution, worktree lifecycle, and final integration must preserve canonical links to sessions, runs, approvals, artifacts, audit records, and validated project-context bindings. @@ -96,6 +97,12 @@ This keeps "always try to keep useful work moving" aligned with the fail-closed - Session execution orchestration freezes the core rule that pending user input is dependency-aware partial blocking rather than a whole-system stop signal. - This change extends that rule across explicit or inferred implementation tracks and isolated worktree execution. +## Relationship To Beta Implementation Baseline + +- `CHG-2026-060-c1a4-beta-readiness-hardening-product-polish` proves that `approved_change_implementation` can run as a local, canonical, single-lane implementation flow through trusted `RunPlan` authority, real runner reporting, local workspace mutation, and evidence-backed operator surfaces. +- This change must consume that baseline rather than bypassing it with a track-local planner or worktree-local runtime authority. +- Track decomposition, worktree execution, and unrelated-track continuation are optional later broadening layers; if they are unsafe or unavailable, the conservative CHG-060-style implementation path remains the fallback posture. + ## Policy, Approval, And Autonomy Controls - Formal approval frequency remains under the canonical approval-profile model. @@ -117,7 +124,7 @@ This keeps "always try to keep useful work moving" aligned with the fail-closed - Track execution should reuse shared workflow identity, policy, approval, audit, and project-context contracts rather than inventing track-local variants of those authority surfaces. - Track execution should also reuse shared dependency-fetch identity, approval, and cache-ownership contracts so parallel worktrees do not drift into package-manager-local or path-local dependency semantics. - Any future track-aware workflow/process definition additions should build on the refined CHG-050 split between `WorkflowDefinition`, `ProcessDefinition`, and immutable `RunPlan` rather than creating a second executable planning format. -- First-party approved-change implementation should be able to adopt this track model later without inventing workflow-pack-local decomposition semantics or reopening the reviewed implementation-input-set authority model frozen by CHG-049. +- First-party approved-change implementation should be able to adopt this track model later without inventing workflow-pack-local decomposition semantics, reopening the reviewed implementation-input-set authority model frozen by CHG-049, or weakening the CHG-060 beta implementation baseline. ## Main Workstreams - Broker-Owned Track Decomposition Model diff --git a/runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/proposal.md b/runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/proposal.md index f7e10d30..28c02d50 100644 --- a/runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/proposal.md +++ b/runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/proposal.md @@ -6,11 +6,14 @@ Even with session execution orchestration and durable wait/resume semantics, imp At the same time, naive parallelization in one shared workspace risks collisions, hidden dependency mistakes, and client-local scheduling semantics that bypass the broker-owned lifecycle and policy model. +`CHG-2026-060-c1a4-beta-readiness-hardening-product-polish` now owns the first beta proof that `approved_change_implementation` can run through the real trusted `RunPlan`, runner reporting, local workspace mutation, and evidence-backed product path. This change therefore starts after that single-lane approved implementation baseline exists; it does not introduce the first approved implementation path. + ## Proposed Change - One broker-owned implementation-track model with stable track identity, dependency edges, and explicit blocked/unblocked readiness. - Track decomposition that consumes the reviewed implementation-input-set foundation from `CHG-2026-049-1d4e-first-party-runecontext-workflow-pack-v0`, can use explicit track declarations from approved canonical inputs when they exist, and can infer candidate tracks when they do not. - A broker-owned proposed execution-plan artifact so inferred decomposition remains auditable, reviewable, and operator-visible rather than a hidden runtime heuristic. - Explicit alignment with CHG-050 so the proposed execution-plan artifact remains planning/review state, while actual runner-consumed runtime authority still flows through broker-compiled immutable `RunPlan`. +- Explicitly additive posture over the CHG-060 single-lane approved implementation proof; this change extends that path with decomposition, isolated worktrees, partial blocking, and safe continuation of unrelated tracks. - Isolated git-worktree execution for low-coupling eligible tracks when confidence, dependency state, policy, and coordination posture allow it. - Explicitly additive posture over the `CHG-049` `v0` baseline of at most one mutation-bearing shared-workspace run per authoritative repository root; this change is where later reviewed multi-track implementation execution becomes explicit. - Dependency-aware partial blocking so pending operator input or approval freezes only the directly affected tracks and downstream dependent tracks, while unrelated eligible tracks may continue. @@ -34,6 +37,7 @@ Planning it now avoids a later split between: - When explicit track declarations are absent, inferred tracks should still become broker-owned proposed execution-plan state rather than remaining hidden agent-local reasoning. - Git worktrees are the preferred isolation substrate for low-coupling implementation tracks, but only when overlap risk and dependency ambiguity remain low enough for safe reviewed use. - Worktree paths, branch names, and local filesystem mechanics remain implementation-private and non-authoritative. +- The CHG-060 beta path proves approved implementation without requiring track decomposition or isolated worktree execution. - `CHG-2026-049-1d4e-first-party-runecontext-workflow-pack-v0` freezes the reviewed implementation-input-set authority model and the initial `v0` single mutation-bearing shared-workspace baseline this change extends later rather than redefines locally. ## Out of Scope @@ -54,3 +58,7 @@ This change remains explicitly additive over CHG-050: It also remains explicitly additive over CHG-049: - approved implementation work already binds to reviewed implementation-input sets and exact digests before this change - this change adds reviewed decomposition, isolation, and coordination behavior on top of that foundation rather than reopening approved-input authority or ambient-repo heuristics + +And it remains explicitly additive over CHG-060: +- CHG-060 proves the first local canonical approved implementation path through the real product architecture +- this change broadens that path into explicit multi-track and isolated-worktree execution only after the baseline is already honest and inspectable diff --git a/runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/standards.md b/runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/standards.md index f86d30ed..bea25de1 100644 --- a/runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/standards.md +++ b/runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/standards.md @@ -19,8 +19,10 @@ That includes freezing the following clarifications for this future foundation: - unrelated eligible tracks may continue only when plan, dependency graph, policy, coordination state, and project-substrate posture all allow it - git worktree mechanics remain implementation-private while broker-owned track, session, run, approval, artifact, audit, and project-context identities remain canonical -This change builds on session execution orchestration, workflow definition binding, and first-party workflow-pack foundations rather than redefining those authority surfaces locally. +This change builds on session execution orchestration, workflow definition binding, first-party workflow-pack foundations, and the CHG-060 beta approved implementation baseline rather than redefining those authority surfaces locally. That now also includes the `CHG-049` clarifications that: - approved implementation work is already bound to reviewed implementation-input sets and exact digests before this change starts decomposing it - the initial `v0` baseline remains at most one mutation-bearing shared-workspace run per authoritative repository root unless and until later reviewed concurrency or worktree execution rules explicitly extend it + +It also includes the CHG-060 clarification that the first beta approved implementation path is a local, canonical, single-lane implementation flow through trusted `RunPlan` authority, real runner reporting, local workspace mutation, and evidence-backed operator surfaces. This change extends that baseline with decomposition and isolated worktree behavior only after the baseline exists. diff --git a/runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/tasks.md b/runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/tasks.md index d510ccd7..1f1977e4 100644 --- a/runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/tasks.md +++ b/runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/tasks.md @@ -4,6 +4,7 @@ - [ ] Define a broker-owned implementation-track model with stable track identity, dependency edges, and readiness/blocking posture. - [ ] Consume the reviewed implementation-input-set authority model from `CHG-2026-049-1d4e-first-party-runecontext-workflow-pack-v0` rather than ambient repository planning state. +- [ ] Consume the CHG-060 single-lane approved implementation baseline rather than defining the first approved implementation path in this change. - [ ] Support explicit track declarations from approved canonical implementation inputs. - [ ] Support inferred candidate tracks when explicit track declarations are absent. - [ ] Make explicit track declarations authoritative over inferred grouping. @@ -11,6 +12,7 @@ - [ ] Carry enough confidence or overlap-risk information for operator review and orchestration policy. - [ ] Keep the proposed execution-plan artifact as planning/review state rather than a second runner-consumed runtime authority beside CHG-050 immutable `RunPlan`. - [ ] Keep later multi-track execution explicitly additive over the CHG-049 `v0` baseline of at most one mutation-bearing shared-workspace run per authoritative repository root. +- [ ] Keep later multi-track execution explicitly additive over the CHG-060 real product implementation path of trusted `RunPlan`, real runner reporting, local workspace mutation, and evidence-backed operator surfaces. ## Git Worktree Execution Lifecycle @@ -58,3 +60,4 @@ - [ ] Track execution reuses shared policy, approval, audit, lifecycle, and validated project-context binding models instead of inventing parallel semantics. - [ ] Track execution reuses shared dependency-fetch and offline-cache contracts so worktrees consume derived dependency artifacts without becoming authoritative dependency cache owners. - [ ] This change remains additive over the CHG-049 `v0` baseline instead of silently redefining approved-input authority or pretending the single mutation-bearing shared-workspace posture never existed. +- [ ] This change remains additive over the CHG-060 beta implementation baseline instead of replacing the first approved implementation path with decomposition or worktree requirements. diff --git a/runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/verification.md b/runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/verification.md index 8ad91ec4..3a4d9feb 100644 --- a/runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/verification.md +++ b/runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/verification.md @@ -8,6 +8,7 @@ ## Verification Notes - Confirm the change defines a broker-owned implementation-track model with stable track identity and dependency edges. - Confirm reviewed implementation-input sets from CHG-049 remain the authoritative upstream implementation inputs rather than ambient repository planning state. +- Confirm CHG-060 remains the first beta owner for the single-lane approved implementation path through trusted `RunPlan`, real runner reporting, local workspace mutation, and evidence-backed product surfaces. - Confirm explicit track declarations override inferred grouping. - Confirm inferred decomposition becomes a broker-owned proposed execution-plan artifact rather than a hidden runtime heuristic. - Confirm the proposed execution-plan artifact remains planning/review state and does not become a second runner-consumed runtime authority alongside CHG-050 immutable `RunPlan`. @@ -26,6 +27,7 @@ - Confirm canonical linkage among tracks, sessions, runs, approvals, artifacts, audit records, and project context remains broker-owned and explicit. - Confirm this change remains additive over CHG-050: executable graph structure and scoped blocking semantics come from the shared workflow substrate, while actual later parallel/worktree execution behavior is introduced here rather than promised earlier. - Confirm this change remains additive over the CHG-049 `v0` baseline of at most one mutation-bearing shared-workspace run per authoritative repository root rather than silently replacing that baseline. +- Confirm this change remains additive over CHG-060 rather than replacing the beta implementation baseline with decomposition or isolated-worktree requirements. - Confirm the roadmap and change text both place this feature in `vNext (Planned)`. ## Close Gate diff --git a/runecontext/changes/CHG-2026-052-a7f1-tui-leader-sequences-command-mode-v0/status.yaml b/runecontext/changes/CHG-2026-052-a7f1-tui-leader-sequences-command-mode-v0/status.yaml index 9f95e8c9..74f34e75 100644 --- a/runecontext/changes/CHG-2026-052-a7f1-tui-leader-sequences-command-mode-v0/status.yaml +++ b/runecontext/changes/CHG-2026-052-a7f1-tui-leader-sequences-command-mode-v0/status.yaml @@ -17,6 +17,7 @@ related_changes: - CHG-2026-040-2b7f-session-transcript-model-v0 - CHG-2026-043-8e9b-live-activity-watch-streams-v0 - CHG-2026-045-7f4c-direct-credential-model-providers-v0 + - CHG-2026-060-c1a4-beta-readiness-hardening-product-polish depends_on: - CHG-2026-013-d2c9-minimal-tui-v0 - CHG-2026-037-91be-tui-multi-session-power-workspace-v0 diff --git a/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/design.md b/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/design.md index 4a293411..3a9d6d96 100644 --- a/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/design.md +++ b/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/design.md @@ -1,266 +1,259 @@ # Design ## Overview -This change records the corrected performance investigation findings and turns them into a project-wide performance-verification design. +This change records the corrected performance investigation findings and turns them into RuneCode's first MVP-grade performance-verification design. -The design goal is not just to benchmark the TUI. It is to give RuneCode one deterministic, CI-compatible performance program spanning: +The design goal is not to benchmark every current or future product surface. It is to give RuneCode one deterministic, CI-compatible performance program for the supported `v0.1.0-beta.1` surface spanning: -- TUI idle, active, attach, and render behavior +- TUI idle, waiting, attach, and render behavior - broker local API request and watch paths -- runner and workflow execution paths +- runner startup and the supported MVP workflow execution path - launcher backend startup and attach readiness - required runtime attestation verification and attestation verification-cache behavior - model-gateway and secrets overhead - dependency-fetch and offline-cache overhead - audit, protocol, and verification costs - external audit anchoring prepare, execute, deferred completion, and receipt-admission costs -- git gateway and project-substrate flows -- end-to-end attach, resume, and execution behavior +- end-to-end attach, resume, and execution behavior on Linux -## Investigation Scope And Constraints +The broader performance expansion remains a separate post-MVP lane in `CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0`. -### Investigation Goals -The investigation was driven by a user report that the TUI: +## Foundation Decisions -- raised apparent system CPU from roughly `1-3%` to roughly `15-20%` -- felt laggy even immediately after launch -- became worse the longer it stayed open +### One Architecture Across Constrained And Scaled Environments -The investigation goals were therefore: +This change freezes the same architecture rule already established in the related dependency, workflow, and attestation changes: -- actually launch and exercise the TUI rather than speculate from code alone -- identify whether the issue was true empty-idle CPU or a specific active-state path -- profile likely render, watch, and allocation hot spots -- collect enough evidence to propose performance checks and thresholds for the whole project +- RuneCode must optimize one topology-neutral authority model across Raspberry Pi-class local hardware, ordinary developer workstations, and later vertically or horizontally scaled deployments. +- Performance work must improve the shared broker-owned, audit-preserving, trust-boundary-respecting architecture rather than introducing environment-specific fast paths or alternate authority models. +- No metric or gate may reward implementation shortcuts that bypass policy, audit, replay protection, attestation order, broker-owned lifecycle truth, or other reviewed control-plane responsibilities. -### Constraints During Investigation -- no source changes -- use the real TUI and broker, not a plan-only review -- limited host tooling: `perf`, `pidstat`, and `expect` were not available -- terminal measurements therefore used PTY harnessing, `/proc//stat`, captured transcripts, and focused `go test` plus `pprof` +### Separate Performance Contract Artifacts -## Measurement Methodology And Corrective Finding +Performance baselines for this change must not be stored in `runecontext/assurance/baseline.yaml`. -### Initial PTY And Profiling Approach -The investigation used: +That file is already part of the project-substrate assurance posture and should remain dedicated to that purpose. This change should instead define one separate reviewed performance-contract artifact family under `tools/perfcontracts/` that stores metric identity, fixture identity, environment authority, statistical policy, and threshold declarations for the performance program. -- throwaway built binaries for `runecode`, `runecode-broker`, and `runecode-tui` -- a PTY harness via `script` -- direct child PID capture through `/proc//task//children` -- `/proc//stat` CPU delta sampling for the real `runecode-tui` child -- focused `go test ./cmd/runecode-tui` runs with CPU and memory profiles +The first artifact family should use: -### Runtime-Direction Permission Gotcha -The broker local IPC runtime directory must be `0700`. The investigation encountered and corrected: +- `tools/perfcontracts/manifest.json` as the reviewed inventory for performance contract files +- per-surface reviewed contract files under `tools/perfcontracts/contracts/` +- reviewed fixture inventory under `tools/perfcontracts/fixtures/` +- optional reviewed baseline sample artifacts under `tools/perfcontracts/baselines/` only when a metric needs repeated-sample comparison against preserved historical samples +- one trusted repo-local compare/enforce entrypoint at `tools/perfcontracts/main.go` that reads these artifacts and check outputs but never rewrites baselines during normal CI -- `local ipc startup failed: broker local runtime directory permissions must be 0700: got 755` +The first reviewed artifact family should be explicit enough to capture at least: -This is an environment-setup requirement, not the core performance problem, but it matters for any future PTY-based verification harness. +- `metric_id` +- subsystem or surface identity +- runtime regime identity +- fixture identity +- measurement kind and unit +- authoritative environment +- sampling policy +- budget class +- explicit threshold or regression allowance +- lane authority +- activation state +- baseline source +- comparison method +- practical noise floor +- threshold origin +- notes or review rationale when needed -### Most Important Measurement Correction -The first live "isolated" TUI run was only socket-isolated, not store-isolated. +Each metric contract should also declare: -The investigation initially used a separate: +- `start_event` +- `end_event` +- `clock_source` +- `evidence_source` +- `included_phases` -- `--runtime-dir` -- `--socket-name` +Those fields make timing boundaries reviewable and prevent implementation from moving a metric to an earlier advisory milestone without changing the reviewed contract. -for broker and TUI, but later confirmed that `runecode-broker serve-local --runtime-dir ...` only changes the local IPC socket location. It does not isolate the broker store or audit ledger unless `--state-root` and `--audit-ledger-root` are also provided. +### Metric Taxonomy -That meant the first live measurement was still reading repo-scoped broker state and inherited preexisting active or waiting sessions. +The first gate set should freeze one metric taxonomy so each check uses the right contract model instead of one generic performance bucket: -The corrected empty-state baseline therefore required all of the following to be isolated together: +- exact checks: + - deterministic event counts + - duplicate-work counts + - CAS write counts + - other invariant counts that should not vary across runs +- absolute budgets: + - user-visible attach and startup ceilings + - key-response ceilings + - CPU and similar operator-visible ceilings where the product promise is explicit +- regression budgets: + - repeated microbenchmarks + - stable allocation-heavy hot paths + - stable deterministic verification suites where historical regression is the main risk +- hybrid budgets: + - paths that need both a reviewed absolute product ceiling and a relative regression budget against a checked-in baseline -- broker `--state-root` -- broker `--audit-ledger-root` -- broker `--runtime-dir` -- broker `--socket-name` -- TUI `--runtime-dir` -- TUI `--socket-name` -- a distinct `RUNECODE_TUI_BROKER_TARGET` alias for local preference isolation +### Statistical Defaults -This correction materially changed the interpretation of the results and is part of the durable planning record for future performance work. +The first implementation slice should start with these statistical defaults and tune them only after implementation and validation data shows a concrete need: -## Measured Findings +- repeated microbenchmarks: + - use repeated samples rather than one-run comparisons + - use robust comparison appropriate for noisy non-normal benchmark data + - require a practical noise-floor threshold in addition to statistical significance so tiny but detectable changes do not cause gate churn +- latency metrics: + - run a fixed number of repeated trials + - record median and `p95` + - gate on explicit reviewed ceilings, with median retained as supporting diagnostic context +- CPU and process-behavior metrics: + - use fixed observation windows after explicit warmup + - summarize repeated runs with average or median and a max guardrail + - avoid pretending that high-noise metrics deserve more inferential precision than the environment can support +- exact metrics: + - compare as exact values or hard bounds rather than inferential tests -### Corrected Empty-State Baseline -With a truly isolated broker store, audit ledger, runtime directory, and socket, the real `runecode-tui` child process measured: +For Go microbenchmarks and similar repeated local measurements, the initial implementation may use a `benchstat`-style comparison workflow or equivalent robust repeated-sample comparison logic, but the durable product rule is the metric-class policy above rather than a tool-specific implementation detail. -- fresh idle CPU: `0.50%` -- mid idle CPU: `1.00%` -- aged idle CPU: `0.67%` -- simple key-to-output timing proxy: `31.4ms` - -This supports the conclusion that RuneCode TUI empty-state idle CPU is already near the expected low baseline and does not support the broad claim that the TUI inherently idles at `15-20%` or worse with no active work. - -### Non-Empty-State Live Sample -Before the store-isolation correction, the real `runecode-tui` child measured: - -- fresh CPU: `0.67%` -- mid CPU: `22.81%` -- aged CPU: `61.92%` -- key-to-output timing proxy: `17.9ms` - -The captured transcript showed that the shell had entered active live-activity mode and was reporting an active session: +### Timing Boundary Rule -- `active_session=sess-manual-multiwait` +Performance timing boundaries must terminate on reviewed authoritative milestones whenever they exist. -That sample is still useful, but it must be interpreted as an active or waiting-state sample, not an empty-idle sample. +That means: -### Terminal Write-Volume Note -The PTY timing trace for the active-state run showed little output over roughly `73s`, which suggests the CPU cost in that regime is not explained solely by high PTY write throughput. The shell can consume significant CPU through internal render, wrap, measurement, and update work even when terminal output is not continuously flooding the screen. +- prefer broker-owned typed lifecycle posture, persisted evidence, persisted verification outputs, or durable broker-projected state over launcher-local, client-local, or transcript-scrape heuristics +- where operator experience and authoritative completion are both important, the design may capture both as separate metrics or sub-metrics, but it must not silently substitute an earlier advisory milestone for the authoritative one -## Source-Level Findings +This rule is especially important for: -### TUI Activity And Polling Model -The TUI currently combines several recurring work sources: +- attach and resume readiness +- runner startup from immutable `RunPlan` +- signed runtime startup and attach-ready behavior +- truthful post-handshake attestation verification +- external anchor prepare, execute, deferred handoff, and completion visibility -- shell watch polling every `2s` - - `cmd/runecode-tui/shell_watch_transport.go` -- activity animation tick every `120ms` while activity state is `running` - - `cmd/runecode-tui/shell_watch_transport.go` - - `cmd/runecode-tui/shell_update.go` -- mouse cell-motion capture by default - - `cmd/runecode-tui/shell_model.go` +### Fixture Scope Rule -### Active-State Classification -The current activity projection treats several waiting or incomplete conditions as actively progressing: +The MVP gate set should start with one small reviewed fixture inventory per major surface rather than broad ladder coverage. -- run lifecycle text containing values such as `active`, `run`, `progress`, `queue`, `wait`, or `pending` -- approval status containing `pending`, `requested`, or `wait` -- any session with `HasIncompleteTurn == true` -- session status containing `active`, `run`, `progress`, `wait`, or `queued` +The first durable slice should prefer one golden deterministic fixture per major surface, with additional buckets only where a distinct runtime regime or scalability posture is already product-relevant. Larger fixture ladders, heavier extended lanes, and broader scale confidence work remain explicit post-MVP expansion work under `CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0`. -That is semantically reasonable for visibility, but it means long-lived waiting sessions can keep the shell in a continuous animation regime even when little visibly changes. +### Linux Environment Authority -### Full-Surface Render Cost -The current render path does more work than necessary for an animation-only frame change: +Linux remains the first authoritative numeric-gate environment, but this change should be honest about measurement noise. -- `activeShellSurface()` calls the route's `ShellSurface()` twice - - once with a base context to derive layout needs - - again with resolved regions after layout planning -- overlay-height calculation recomputes surface and layout again through `activeShellSurfaceWithoutOverlayHeight()` -- view rendering then rebuilds the whole workbench frame +- Shared hosted Linux CI is acceptable for the initial required-gate slice where thresholds are conservative enough to remain deterministic. +- The design should allow selected higher-noise metrics to be promoted later to a tighter authoritative Linux environment without changing metric identity or product architecture. +- The performance program must not require a second product architecture merely because measurement infrastructure differs. -That means a small state change such as `activityFrame` advancing can still trigger expensive whole-shell recomputation. +### Lane And Activation States -### Watch Fan-Out And Discoverability Refresh -Each shell watch application currently: +Each performance metric should declare both a lane authority and an activation state. -- updates the watch reduction and projection -- publishes live activity to every route model -- refreshes the shell discoverability index from watch-derived state -- rebuilds palette entries immediately if the palette is open +Initial lane authorities: -The route fan-out is somewhat bounded because only a subset of routes currently consume the live-activity message, but the current model is still broader than "active route only" and is worth gating and profiling. +- `required_shared_linux`: required in the Linux PR path on shared hosted Linux because the metric is stable enough with conservative thresholds +- `required_tight_linux`: required for beta closure, but measured in a tighter authoritative Linux environment because shared hosted Linux is too noisy +- `informational_until_stable`: collected in CI or local verification until calibration data proves it is stable enough to become required +- `contract_pending_dependency`: contract and harness may be authored, but the gate cannot become required until the underlying reviewed path exists +- `extended`: non-PR, merge-queue, scheduled, or post-MVP measurement -### Watch Transport Semantics -The current watch transport asks for: +Initial activation states: -- `IncludeSnapshot: true` -- `Follow: true` +- `defined`: contract exists but no enforcement yet +- `informational`: measurement runs but does not block +- `required`: measurement blocks in its declared lane +- `contract_pending_dependency`: contract exists but depends on another reviewed path before it can run authoritatively -for run, approval, and session watch families. +`CHG-025` external-anchor metrics and `CHG-054` truthful-attestation metrics may be defined before those changes fully land, but they must remain `contract_pending_dependency` until the reviewed path exists. -The broker-side watch builders return batches with explicit snapshot, upsert, and terminal event types derived from current summaries. This is deterministic and correct, but it means each watch poll can re-feed a nontrivial amount of state through reduction, projection, discoverability, and render paths. - -## Profile Findings - -### Render CPU Hot Spots -Focused shell view profiling identified heavy cumulative CPU cost in: - -- `github.com/charmbracelet/x/ansi.stringWidth` -- `github.com/charmbracelet/x/cellbuf.Wrap` -- `github.com/charmbracelet/bubbles/textarea.Model.placeholderView` - -### Render Allocation Hot Spots -Focused shell view allocation profiling identified major allocators in: +## Investigation Scope And Constraints -- `github.com/charmbracelet/x/ansi.(*Parser).SetDataSize` -- `regexp/syntax.(*compiler).inst` -- `github.com/runecode-ai/runecode/cmd/runecode-tui.chatRouteModel.ShellSurface` +### Investigation Goals +The investigation was driven by a user report that the TUI: -### Watch And Update Allocation Hot Spots -Focused watch-heavy profiling identified significant allocation cost in: +- raised apparent system CPU from roughly `1-3%` to roughly `15-20%` +- felt laggy even immediately after launch +- became worse the longer it stayed open -- `github.com/runecode-ai/runecode/cmd/runecode-tui.shellModel.buildPaletteEntries` -- `github.com/charmbracelet/x/ansi.(*Parser).SetDataSize` -- `regexp/syntax.(*compiler).inst` +The investigation goals were therefore: -### Recorded Profile Totals -The investigation recorded the following notable profile excerpts: +- actually launch and exercise the TUI rather than speculate from code alone +- identify whether the issue was true empty-idle CPU or a specific waiting-state path +- profile likely render, watch, and allocation hot spots +- collect enough evidence to propose deterministic MVP beta checks -- shell-view-focused memory profile total: `419.63MB` - - `regexp/syntax.(*compiler).inst`: `119.71MB` - - `github.com/charmbracelet/x/ansi.(*Parser).SetDataSize`: `113.63MB` -- watch-heavy memory profile total: `749.40MB` - - `github.com/charmbracelet/x/ansi.(*Parser).SetDataSize`: `234.21MB` - - `regexp/syntax.(*compiler).inst`: `205.92MB` -- cumulative watch-heavy allocation in `shellModel.buildPaletteEntries`: `138.17MB` -- cumulative shell-view allocation in `chatRouteModel.ShellSurface`: `94.27MB` +### Constraints During Investigation +- no source changes during the original investigation +- use the real TUI and broker, not a plan-only review +- limited host tooling: `perf`, `pidstat`, and `expect` were not available +- terminal measurements therefore used PTY harnessing, `/proc//stat`, captured transcripts, and focused `go test` plus `pprof` -### Corrected Interpretation -The corrected interpretation is narrower and more useful than the initial broad concern: +## Measurement Methodology And Corrective Finding -- empty-state idle is roughly acceptable -- active or waiting-session mode can still be too expensive because a `120ms` repaint loop hits a heavy whole-shell render path -- the project therefore needs separate gates for empty idle, active waiting-state cost, render microbenchmarks, and broker or watch paths +### Runtime-Direction Permission Gotcha +The broker local IPC runtime directory must be `0700`. The investigation encountered and corrected: -## Best-Practice Guidance Collected During Research -External Go, Bubble Tea, Bubbles, Lip Gloss, and `pprof` guidance converged on a consistent set of themes that match the investigation: +- `local ipc startup failed: broker local runtime directory permissions must be 0700: got 755` -- minimize background ticks, polls, and animation frequency when there is no true visible progress requirement -- use lower animation or FPS ceilings when a state is "waiting" rather than actively changing -- avoid unnecessary mouse-motion capture when click and wheel handling is sufficient -- cache or reuse expensive view fragments instead of rebuilding the full surface on small state changes -- avoid repeated width measurement and wrapping work in hot render loops -- avoid rebuilding regex, parser, and search structures on hot paths when inputs have not meaningfully changed -- use benchmark and profile regression gates in CI rather than relying on one-time local profiling +This is an environment-setup requirement, not the core performance problem, but it matters for future PTY-based verification harnesses. -## Durable Product-Level Conclusions +### Most Important Measurement Correction +The first live "isolated" TUI run was only socket-isolated, not store-isolated. -### Conclusion 1: Empty Idle And Active Waiting Must Be Gated Separately -The most important planning correction is that RuneCode should not have one undifferentiated TUI performance gate. +The corrected empty-state baseline therefore required all of the following to be isolated together: -It needs at least two distinct regimes: +- broker `--state-root` +- broker `--audit-ledger-root` +- broker `--runtime-dir` +- broker `--socket-name` +- TUI `--runtime-dir` +- TUI `--socket-name` +- a distinct `RUNECODE_TUI_BROKER_TARGET` alias for local preference isolation -- empty or quiescent local state -- active or waiting session state +This correction materially changed the interpretation of the results and is part of the durable planning record for future performance work. -### Conclusion 2: Waiting State Is The Higher-Risk User Regime -The investigation suggests the likely user-facing performance pain is not the empty shell. It is the long-lived waiting state where: +## Measured Findings -- activity semantics keep the shell in `running` -- a `120ms` animation tick remains armed -- whole-shell render work remains expensive +### Corrected Empty-State Baseline +With a truly isolated broker store, audit ledger, runtime directory, and socket, the real `runecode-tui` child process measured: -Alpha.7 now partially addresses this specific risk by splitting waiting from running in shell activity semantics, preserving visible waiting cues without keeping the `120ms` running animation armed, and adding focused `cmd/runecode-tui` benchmarks for shell view, watch apply, and palette entry construction. The broader architectural work below remains deferred. +- fresh idle CPU: `0.50%` +- mid idle CPU: `1.00%` +- aged idle CPU: `0.67%` +- simple key-to-output timing proxy: `31.4ms` -The post-implementation live rerun materially improved the targeted regime. Using a deterministic isolated multiwait fixture over local IPC, the real `runecode-tui` child measured: +This supports the conclusion that RuneCode TUI empty-state idle CPU is already near the expected low baseline. -- empty state: `0.20%` fresh, `0.80%` mid, `0.80%` aged -- waiting state after the alpha.7 fix: `0.00%` fresh, `1.00%` mid, `1.00%` aged -- prior waiting-state sample before the fix: `0.67%` fresh, `22.81%` mid, `61.92%` aged +### Waiting-State Risk +Before the store-isolation correction, a non-empty-state live sample climbed through `22.81%` and `61.92%` CPU while the shell reported active session state. After the alpha.7 waiting-state fix, the isolated waiting-state rerun measured: -That result is the strongest current evidence that the alpha.7 waiting-state split fixed the user-facing repaint regression it targeted. The waiting sample still rendered an explicit `WAITING session=sess-manual-multiwait` marker in the captured transcript, so the CPU drop did not come from hiding the state entirely. +- `0.00%` fresh CPU +- `1.00%` mid CPU +- `1.00%` aged CPU -### Conclusion 3: Performance Verification Must Be Cross-Cutting -The TUI findings are the most concrete current example, but the same failure mode can exist elsewhere: regressions remain invisible until a human notices because no deterministic subsystem budgets exist in CI. +The strongest current evidence is therefore: -## Proposed Performance Verification Architecture +- empty-state idle is roughly acceptable +- waiting state was the higher-risk user regime +- the alpha.7 fix materially improved that specific repaint regression -### Governing Principles -- Use deterministic local fixtures, seeded stores, stubbed providers, and local bare remotes rather than live external services. -- Keep performance verification check-only and CI-safe. -- Split thresholds by runtime regime and subsystem. -- Use Linux CI as the first authoritative numeric gate. -- Run the same flows on macOS and Windows where feasible, initially as smoke or trend gates until platform-specific thresholds are tuned. -- Combine absolute thresholds with regression thresholds so the project gets both hard ceilings and drift detection. +## MVP Performance Regimes +The MVP gate set should distinguish at least these regimes: -### Baseline-Maintenance Policy -- For checks already supported by current evidence, commit explicit absolute thresholds immediately. -- For checks without current measured baselines, bootstrap them with deterministic fixture runs and fail on regression beyond the configured percentage from the committed baseline artifact or benchmark snapshot. -- Tighten thresholds intentionally through review rather than letting CI baselines mutate automatically. +- empty or quiescent local state +- waiting-session local state +- attach and resume latency +- render and update hot paths +- broker request and watch latency +- supported workflow startup and execution +- launcher startup and attach-ready behavior +- attestation cold and warm verification cost +- model-gateway, dependency-fetch, audit, protocol, and external-anchor overhead + +The first gate set should also preserve the distinction between: + +- user-visible experience regimes +- repeated microbenchmark hot paths +- process-behavior resource regimes +- exact-count or invariant-preservation regimes ## Performance Check Matrix @@ -268,158 +261,212 @@ The TUI findings are the most concrete current example, but the same failure mod | Aspect | Fixture | Check | Initial Threshold | CI Lane | | --- | --- | --- | --- | --- | -| Empty idle CPU | isolated empty broker state, isolated runtime/socket/target alias | sample real `runecode-tui` child CPU for 60s | average `<= 2%`, max sample `<= 4%` | required Linux | -| Waiting-state CPU | deterministic waiting session fixture in isolated broker store | sample real `runecode-tui` child CPU for 60s | average `<= 8%`, max sample `<= 12%` | required Linux | -| Attach/startup | isolated broker store with no pending work | PTY launch to first settled full frame | `<= 500ms` to first settled frame | required Linux | -| Key-response latency | quiet route, empty and waiting-state fixtures | key inject to transcript delta proxy | p95 `<= 50ms` empty, p95 `<= 75ms` waiting-state | required Linux | -| Render microbenchmarks | synthetic route surfaces and shell states | `BenchmarkShellViewEmpty`, `BenchmarkShellViewWaitingSession`, `BenchmarkShellViewPaletteOpen` | fail on `> 15%` regression in `ns/op`, `B/op`, or `allocs/op` from committed Linux baseline | required Linux | -| Update microbenchmarks | synthetic watch messages and command-surface states | `BenchmarkShellWatchApply`, `BenchmarkBuildPaletteEntries` | fail on `> 15%` regression in `ns/op`, `B/op`, or `allocs/op` | required Linux | +| Empty idle CPU | isolated empty broker state, isolated runtime/socket/target alias | sample real `runecode-tui` child CPU for fixed repeated windows after explicit warmup | average `<= 2%`, max sample `<= 4%` | `informational_until_stable`, promote to `required_shared_linux` or `required_tight_linux` after calibration | +| Waiting-state CPU | deterministic waiting session fixture in isolated broker store | sample real `runecode-tui` child CPU for fixed repeated windows after explicit warmup | average `<= 8%`, max sample `<= 12%` | `informational_until_stable`, promote to `required_shared_linux` or `required_tight_linux` after calibration | +| Attach/startup | isolated broker store with no pending work | PTY launch to first settled full frame after broker-owned attachable posture is reached | `<= 500ms` to first settled frame | `required_shared_linux` after timing contract is frozen | +| Key-response latency | quiet route, empty and waiting-state fixtures | fixed repeated trials from key inject to transcript delta proxy | p95 `<= 50ms` empty, p95 `<= 75ms` waiting-state | `required_shared_linux` after sample count is validated | +| Render microbenchmarks | synthetic route surfaces and shell states | `BenchmarkShellViewEmpty`, `BenchmarkShellViewWaitingSession` | fail on `> 15%` regression in `ns/op`, `B/op`, or `allocs/op` from committed Linux baseline once repeated-sample comparison exceeds the reviewed noise floor | `required_shared_linux` | +| Update microbenchmarks | synthetic watch messages and command-surface states | `BenchmarkShellWatchApply`, `BenchmarkBuildPaletteEntries` | fail on `> 15%` regression in `ns/op`, `B/op`, or `allocs/op` once repeated-sample comparison exceeds the reviewed noise floor | `required_shared_linux` | -### Broker Local API And Watch Families +### Broker Local API, Watch, And Attach Paths | Aspect | Fixture | Check | Initial Threshold | CI Lane | | --- | --- | --- | --- | --- | -| Unary local API latency | deterministic stores with 10, 100, and 500 entity fixtures | `session-list`, `session-get`, `run-list`, `run-get`, `approval-list`, `readiness`, `version-info`, `project-substrate-posture-get` | p95 `<= 75ms` at 10 items, `<= 150ms` at 100, `<= 300ms` at 500; fail on `> 15%` regression | required Linux | -| Watch-family latency | deterministic stores with 10, 100, and 500 entity fixtures | `run-watch`, `approval-watch`, `session-watch`, `session-turn-execution-watch` with `IncludeSnapshot` and `Follow` | p95 `<= 100ms` at 10 items, `<= 200ms` at 100, `<= 400ms` at 500; fail on `> 15%` regression | required Linux | -| Watch payload growth | same watch fixtures | response bytes and event counts | fail if payload grows `> 15%` beyond committed baseline per fixture bucket | required Linux | -| Mutation-path latency | deterministic local stores | `session-execution-trigger`, `continue`, `approval-resolve`, `backend-posture-change` | p95 `<= 200ms` for local control-plane-only paths | required Linux | +| Unary local API latency | deterministic stores for supported beta fixtures | repeated local trials over `session-list`, `session-get`, `run-list`, `run-get`, `approval-list`, `readiness`, `version-info`, `project-substrate-posture-get` | p95 `<= 150ms` for supported fixture sizes and fail on `> 15%` regression where hybrid budgets are used | `required_shared_linux` | +| Watch-family latency | deterministic stores for supported beta fixtures | repeated local trials over `run-watch`, `approval-watch`, `session-watch`, `session-turn-execution-watch` with `IncludeSnapshot` and `Follow` | p95 `<= 200ms` for supported fixture sizes and fail on `> 15%` regression where hybrid budgets are used | `required_shared_linux` | +| Watch payload growth | same fixtures | response bytes and event counts | fail if payload grows `> 15%` beyond committed baseline per supported fixture bucket | `required_shared_linux` | +| Mutation-path latency | deterministic local stores | `session-execution-trigger`, `continue`, `approval-resolve`, `backend-posture-change` | p95 `<= 200ms` for local control-plane-only paths | `required_shared_linux` | +| Local attach | broker already running with isolated state | attach to ready interactive surface after broker-owned lifecycle posture confirms attachability | `<= 500ms` | `required_shared_linux` after timing contract is frozen | +| Resume after reconnect | persisted session/run state with broker already running | detach and reattach workflow to ready broker-owned session/run truth | `<= 500ms` from attach to ready surface | `required_shared_linux` after timing contract is frozen | -### Runner And Workflow Engine +### Runner, Workflow, And Launcher Paths | Aspect | Fixture | Check | Initial Threshold | CI Lane | | --- | --- | --- | --- | --- | -| Runner boundary check | current repo plus deterministic fixture workspace | `cd runner && npm run boundary-check` | wall time `<= 5s`; fail on `> 15%` regression | required Linux, smoke on macOS/Windows | -| Protocol fixture tests | deterministic shared fixture set | `cd runner && node --test scripts/protocol-fixtures.test.js` | wall time `<= 10s`; fail on `> 15%` regression | required Linux, smoke on macOS/Windows | -| Representative runner cold start | no-op or minimal workflow fixture | runner startup to first durable checkpoint | `<= 1s` local-overhead budget | required Linux | -| Representative workflow execution | deterministic no-op and small-change workflows | trigger to completed durable state | `<= 2s` no-op, `<= 5s` small workflow; fail on `> 15%` regression | extended Linux | - -### Control-Plane Attach, Resume, And Session Lifecycle +| Runner boundary check | current repo plus deterministic fixture workspace | `cd runner && npm run boundary-check` | wall time `<= 5s`; fail on `> 15%` regression | `required_shared_linux` | +| Protocol fixture tests | deterministic shared fixture set | `cd runner && node --test scripts/protocol-fixtures.test.js` | wall time `<= 10s`; fail on `> 15%` regression | `required_shared_linux` | +| Representative runner cold start | deterministic minimal workflow fixture | runner startup to first durable checkpoint bound to the active immutable plan identity | `<= 1s` local-overhead budget | `informational_until_stable`, promote after sample stability is proven | +| Supported workflow execution | deterministic MVP workflow fixture | trigger to completed durable broker state on the supported beta slice | threshold derived from committed baseline; fail on `> 15%` regression | `contract_pending_dependency` until the real supported workflow path exists, then promote | +| CHG-050 workflow path | deterministic definitions and process fixtures | validation or canonicalization, trusted compilation, compiled-plan persistence or load, runner startup from immutable `RunPlan` | threshold derived from committed baseline; fail on `> 15%` regression once repeated-sample comparison exceeds the reviewed noise floor | `required_shared_linux` for trusted compilation/load checks; execution startup remains `contract_pending_dependency` until the real path exists | +| MicroVM cold start | deterministic lightweight signed role image with verified-cache miss or required trusted-admission path | trigger to broker-observed ready state | `<= 8s` cold | `informational_until_stable` or `required_tight_linux` after calibration | +| MicroVM warm start | same signed runtime-image fixture with verified local runtime-asset cache hit | trigger to ready | `<= 3s` warm | `informational_until_stable` or `required_tight_linux` after calibration | +| Container cold start | opt-in deterministic signed container-runtime fixture with verified-cache miss or required trusted-admission semantics used for microVM startup checks | trigger to ready | `<= 4s` cold | `informational_until_stable` | +| Container warm start | same signed container-runtime fixture with verified local runtime-asset cache hit | trigger to ready | `<= 2s` warm | `informational_until_stable` | +| Attestation cold path | deterministic runtime startup fixture with full post-handshake verification | launch to persisted post-handshake attestation verification and broker projection | threshold derived from committed baseline; fail on `> 15%` regression | `contract_pending_dependency` until `CHG-054` lands | +| Attestation warm path | same fixture with immutable verification-cache hits | launch to persisted post-handshake attestation verification and broker projection | threshold derived from committed baseline; fail on `> 15%` regression | `contract_pending_dependency` until `CHG-054` lands | + +Launcher and attestation checks must preserve the reviewed architecture rather than rewarding unsafe shortcuts: + +- cold checks measure trusted admission or verified-cache miss cost when assets are not already locally admitted +- warm checks measure verified-cache hit behavior on the same signed runtime identity +- neither path may reward bypassing signer verification, component-digest checks, attestation verification, replay checks, freshness checks, or launch-deny evidence generation + +### Gateway, Dependency, Audit, Protocol, And External Anchor Paths | Aspect | Fixture | Check | Initial Threshold | CI Lane | | --- | --- | --- | --- | --- | -| Local attach | broker already running with isolated state | attach to ready interactive surface | `<= 500ms` | required Linux | -| Resume after reconnect | persisted session/run state with broker already running | detach and reattach workflow | `<= 500ms` from attach to ready surface | required Linux | -| Session execution orchestration readiness | deterministic waiting and resumed-turn fixtures | time to visible status transition in broker-owned state | `<= 250ms` local control-plane propagation | extended Linux | +| Secret-ingress prepare and submit | stubbed deterministic secret payloads | local broker and secrets overhead only | p95 `<= 300ms` for small payloads | `required_shared_linux` | +| Credential lease issuance | deterministic provider-profile fixture | local issuance overhead | p95 `<= 150ms` | `required_shared_linux` | +| Model-gateway invoke overhead | stubbed provider backend returning deterministic responses | RuneCode-added overhead excluding external network | p95 `<= 100ms` added overhead | `required_shared_linux` | +| Dependency cache miss | deterministic dependency-request fixture and stubbed registry payload source | broker-owned fetch to CAS with no existing cached units | threshold derived from committed baseline; fail on `> 15%` regression in wall time, reviewed bounded-buffer metrics, or peak RSS guardrails beyond reviewed budgets | `required_shared_linux` for bounded-buffer/exact counters; wall/RSS may begin `informational_until_stable` | +| Dependency cache hit | same fixture with cached resolved units already present | broker-owned dependency availability request with no network fetch path taken | threshold derived from committed baseline; fail on `> 15%` regression | `required_shared_linux` | +| Dependency miss coalescing | concurrent identical deterministic dependency requests | wall time, duplicate network work count, and CAS write count | require one effective upstream fill per canonical request identity; fail on duplicate-fill regression | `required_shared_linux` for exact duplicate-fill and CAS-write counts | +| Dependency materialization | deterministic cached dependency manifest and units | broker-mediated offline staging or materialization for workspace use | threshold derived from committed baseline; fail on `> 15%` regression | `required_shared_linux` after fixture calibration | +| Dependency stream-to-CAS posture | large deterministic dependency payload fixture | memory and streaming behavior during cache fill | fail if implementation buffers full payloads in memory beyond reviewed budget, violates reviewed bounded-buffer instrumentation limits, or regresses beyond baseline | `required_shared_linux` for bounded-buffer instrumentation; process RSS starts `informational_until_stable` | +| Audit verification | deterministic ledger fixtures | verify end-to-end locally | threshold derived from committed baseline; fail on `> 15%` regression | `required_shared_linux` | +| Audit finalize verify | deterministic local ledger | finalize plus verify | threshold derived from committed baseline; fail on `> 15%` regression | `required_shared_linux` | +| Protocol schema validation | checked-in protocol schemas and fixtures | schema load and validation suite | `<= 2s` for standard CI fixture set | `required_shared_linux` | +| Fixture-manifest parity | protocol fixtures plus manifest | parity and canonicalization checks | `<= 2s` | `required_shared_linux` | +| External anchor prepare | deterministic sealed audit segment plus stubbed target descriptor | prepare request to durable prepared state | p95 `<= 500ms` local control-plane overhead | `contract_pending_dependency` until `CHG-025` lands | +| External anchor execute-completed | deterministic sealed audit segment plus fast stubbed target | execute request to completed authoritative persistence | threshold derived from committed baseline; fail on `> 15%` regression | `contract_pending_dependency` until `CHG-025` lands | +| External anchor execute-deferred handoff | deterministic sealed audit segment plus intentionally delayed stubbed target | execute request to deferred durable state | p95 `<= 500ms` local control-plane overhead | `contract_pending_dependency` until `CHG-025` lands | +| Deferred completion visibility | same delayed stubbed target | deferred completion to durable completed state plus get/watch visibility | threshold derived from committed baseline; fail on `> 15%` regression | `contract_pending_dependency` until `CHG-025` lands | +| Receipt admission on unchanged seal | already-verified sealed segment plus valid stubbed target proof | authoritative receipt and sidecar admission without full seal replay | threshold derived from committed baseline; fail on `> 15%` regression in wall time or peak RSS | `contract_pending_dependency` until `CHG-025` lands | -### Launcher Backends +External audit anchoring checks must preserve the reviewed architecture rather than rewarding unsafe shortcuts: -| Aspect | Fixture | Check | Initial Threshold | CI Lane | -| --- | --- | --- | --- | --- | -| MicroVM cold start | deterministic lightweight signed role image with verified-cache miss or required trusted-admission path | trigger to broker-observed ready state | `<= 8s` cold | extended Linux | -| MicroVM warm start | same signed runtime-image fixture with verified local runtime-asset cache hit | trigger to ready | `<= 3s` warm | extended Linux | -| Container cold start | opt-in deterministic signed container-runtime fixture with verified-cache miss or required trusted-admission path | trigger to ready | `<= 4s` cold | extended Linux | -| Container warm start | same signed container-runtime fixture with verified local runtime-asset cache hit | trigger to ready | `<= 2s` warm | extended Linux | +- network I/O must stay outside the audit-ledger lock +- deferred execution must remain a first-class lifecycle outcome rather than a hidden test bypass +- unchanged verified seals should use the reviewed incremental receipt-admission path rather than forcing full verifier replay as the only normal path +- checks must not bypass authoritative proof verification, policy binding, or audit evidence persistence to produce a lower number -Cold and warm launcher checks should continue to use the same reviewed signed runtime-asset architecture: +### Initial Fixture Inventory -- cold launcher checks measure trusted admission or verified-cache miss cost when launchable assets are not already locally admitted -- warm launcher checks measure verified-cache hit behavior on the same signed runtime identity -- neither path may reward bypassing signer verification, component-digest checks, or launch-deny evidence generation +The initial gate set should start with a small reviewed fixture inventory rather than a broad ladder. -### Model Gateway, Secrets, And Provider Overhead +Recommended first durable slice: -| Aspect | Fixture | Check | Initial Threshold | CI Lane | -| --- | --- | --- | --- | --- | -| Secret-ingress prepare and submit | stubbed deterministic secret payloads | local broker and secrets overhead only | p95 `<= 300ms` for small payloads | extended Linux | -| Credential lease issuance | deterministic provider-profile fixture | local issuance overhead | p95 `<= 150ms` | extended Linux | -| Model-gateway invoke overhead | stubbed provider backend returning deterministic responses | RuneCode-added overhead excluding external network | p95 `<= 100ms` added overhead | extended Linux | +- TUI: + - `tui.empty.v1` + - `tui.waiting.v1` +- broker local API: + - `broker.unary.beta-small.v1` + - `broker.watch.run.snapshot-follow.v1` + - `broker.watch.approval.snapshot-follow.v1` + - `broker.watch.session.snapshot-follow.v1` + - `broker.watch.turn-execution.snapshot-follow.v1` +- runner and workflow: + - `workflow.first-party-minimal.v1` + - `workflow.chg050-compile.v1` +- dependency fetch: + - `deps.cache-miss.small.v1` + - `deps.cache-hit.small.v1` + - `deps.coalesced-miss.small.v1` +- audit: + - `audit.ledger.standard.v1` +- external anchor: + - `anchor.fast-complete.stub.v1` + - `anchor.deferred.stub.v1` +- attestation: + - `attestation.cold.signed-runtime.v1` + - `attestation.warm.signed-runtime.v1` -### Dependency Fetch And Offline Cache +This keeps the first release-defining gate set narrow enough to stay deterministic while still covering the regimes that matter for `v0.1.0-beta.1`. -| Aspect | Fixture | Check | Initial Threshold | CI Lane | -| --- | --- | --- | --- | --- | -| Dependency cache miss | deterministic public-registry fixture with reviewed dependency request object and stubbed registry payload source | broker-owned fetch to CAS with no existing cached units | threshold derived from committed baseline; fail on `> 15%` regression in wall time, peak RSS, or bytes buffered beyond reviewed budget | extended Linux | -| Dependency cache hit | same fixture with cached resolved units already present | broker-owned dependency availability request with no network fetch path taken | threshold derived from committed baseline; fail on `> 15%` regression | extended Linux | -| Dependency miss coalescing | concurrent identical deterministic dependency requests | wall time, duplicate network work count, and CAS write count | require one effective upstream fill per canonical request identity; fail on duplicate-fill regression | extended Linux | -| Dependency materialization | deterministic cached dependency manifest and units | broker-mediated offline staging/materialization for workspace use | threshold derived from committed baseline; fail on `> 15%` regression | extended Linux | -| Dependency stream-to-CAS posture | large deterministic dependency payload fixture | memory and streaming behavior during cache fill | fail if implementation buffers full payloads in memory beyond reviewed budget or regresses beyond baseline | extended Linux | +Fixture IDs are part of metric identity. Future fixture expansion should add new IDs rather than changing these IDs in place unless the fixture semantics intentionally change and the baseline is reviewed as a new contract. -### External Audit Anchoring +## Statistical And Comparison Policy -| Aspect | Fixture | Check | Initial Threshold | CI Lane | -| --- | --- | --- | --- | --- | -| External anchor prepare | deterministic sealed audit segment plus stubbed transparency-log target descriptor | prepare request to durable prepared state | p95 `<= 500ms` local control-plane overhead | extended Linux | -| External anchor execute-completed | deterministic sealed audit segment plus fast stubbed target | execute request to completed authoritative persistence | threshold derived from committed baseline; fail on `> 15%` regression in wall time | extended Linux | -| External anchor execute-deferred handoff | deterministic sealed audit segment plus intentionally delayed stubbed target | execute request to deferred durable state | p95 `<= 500ms` local control-plane overhead | extended Linux | -| Deferred completion visibility | same delayed stubbed target | deferred completion to durable completed state plus get or watch visibility | threshold derived from committed baseline; fail on `> 15%` regression | extended Linux | -| Receipt admission on unchanged seal | already-verified sealed segment plus valid stubbed target proof | authoritative receipt and sidecar admission without full seal replay | threshold derived from committed baseline; fail on `> 15%` regression in wall time or peak RSS | extended Linux | -| Invalid or unavailable target handling | stubbed invalid-proof and unavailable-target fixtures | execute plus verifier posture update | threshold derived from committed baseline; fail on `> 15%` regression | extended Linux | +### Repeated Microbenchmarks -External audit anchoring checks must preserve the reviewed architecture rather than rewarding unsafe shortcuts: +- Run repeated samples rather than one-off benchmark comparisons. +- Use robust repeated-sample comparison logic suitable for non-normal noisy measurements. +- Require both: + - the configured regression threshold to be exceeded + - the change to exceed the reviewed practical noise floor +- Treat summary statistics such as median and confidence intervals as authoritative comparison context, not single best-case runs. -- network I/O must stay outside the audit-ledger lock -- deferred execution must remain a first-class lifecycle outcome rather than a hidden test bypass -- unchanged verified seals should use the reviewed incremental receipt-admission path rather than forcing full verifier replay as the only normal path -- checks must not bypass authoritative proof verification, policy binding, or audit evidence persistence to produce a lower number +Initial constants: -### Audit, Protocol, And Verification Surfaces +- required PR comparisons should use at least `10` repeated samples when runtime cost allows +- baseline refresh or threshold recalibration should preferably use at least `20` repeated samples +- fail only when the configured regression threshold and the reviewed practical noise floor are both exceeded +- use a `benchstat`-style comparison workflow or equivalent robust repeated-sample comparison for Go microbenchmarks -| Aspect | Fixture | Check | Initial Threshold | CI Lane | -| --- | --- | --- | --- | --- | -| Audit verification | deterministic ledger with 1k and 10k records | verify end-to-end locally | `<= 2s` at 1k, `<= 10s` at 10k | extended Linux | -| Audit finalize verify | deterministic local ledger | finalize plus verify | `<= 3s` at standard CI fixture size | extended Linux | -| Protocol schema validation | checked-in protocol schemas and fixtures | schema load and validation suite | `<= 2s` for standard CI fixture set | required Linux | -| Fixture-manifest parity | protocol fixtures plus manifest | parity and canonicalization checks | `<= 2s` | required Linux | +### Latency Metrics -### Git Gateway And Project Substrate Paths +- Run a fixed number of repeated trials per fixture. +- Record median and `p95`. +- Gate on the reviewed explicit ceiling, with median retained as diagnostic context. +- Avoid using statistical significance alone as the gate for user-visible latency promises. -| Aspect | Fixture | Check | Initial Threshold | CI Lane | -| --- | --- | --- | --- | --- | -| Git remote prepare | deterministic local fixture repo | prepare request to response | p95 `<= 500ms` | extended Linux | -| Git execute against local bare remote | local bare remote only, no network | issue execute lease plus execute | `<= 2s` | extended Linux | -| Project substrate posture and preview | deterministic fixture repo | `project-substrate-posture-get`, `adopt`, `init-preview`, `upgrade-preview` | p95 `<= 500ms` for posture and preview flows | extended Linux | -| Project substrate apply | deterministic local fixture repo | `init-apply` or `upgrade-apply` | `<= 2s` for local-only fixture | extended Linux | +Initial constants: + +- cheap local latency metrics should target `30` fixed trials so `p95` is meaningful enough for a required gate +- heavier lifecycle metrics may use median plus max ceilings while they are too expensive for a meaningful `p95` sample size +- each latency metric contract should declare whether `p95`, median plus max, or both are authoritative + +### CPU And Process-Behavior Metrics + +- Use explicit warmup before measurement. +- Use fixed repeated windows. +- Summarize with average or median plus max guardrails. +- Prefer conservative thresholds over false precision in noisy shared environments. + +Initial constants: + +- each metric must declare warmup duration, observation-window duration, and number of repeated windows before it can become required +- shared hosted Linux CPU metrics should start as `informational_until_stable` unless validation data proves the threshold is stable enough to require there +- max guardrails should catch pathological spikes, but sustained average or median window cost should remain the primary CPU signal + +### Exact Metrics + +- Treat deterministic counts, duplicate-work counts, payload counts, and similar invariants as exact checks or hard bounds. +- Do not subject exact metrics to inferential comparison logic. + +### Threshold Provenance + +Every threshold should declare one reviewed `threshold_origin`: + +- `product_budget`: an intentional product promise or safety ceiling +- `investigation_baseline`: derived from the corrected investigation data captured by this change +- `first_calibration`: accepted as an initial calibration value after implementation produces repeatable measurements +- `temporary_guardrail`: intentionally provisional and expected to be revisited after more data + +Threshold loosening should require explicit review rationale and should not be hidden inside baseline refresh mechanics. ## CI Integration Plan -### Required PR Lane -The required Linux PR lane should include the smallest deterministic checks that still catch the main regressions: +### Required Linux PR Lane +The required Linux PR lane should include the smallest deterministic `required_shared_linux` checks that still catch the main MVP regressions without pretending high-noise checks are stable on shared hosted runners: -- TUI empty-idle CPU gate +- TUI empty-idle CPU measurement as informational until stability is proven +- TUI waiting-state CPU measurement as informational until stability is proven - TUI attach/startup gate - TUI key-response gate - TUI render and update microbenchmarks - broker unary local API latency gate - broker watch-family latency gate +- local attach and resume gates - protocol and runner deterministic quick checks +- supported workflow execution contracts, with required enforcement only after the real supported workflow path exists +- launcher startup measurements as informational until stability or tighter Linux authority is available +- attestation cold or warm contracts as `contract_pending_dependency` until `CHG-054` lands +- deterministic model-gateway, dependency-fetch, audit, and protocol quick checks +- external-anchor contracts as `contract_pending_dependency` until `CHG-025` lands -### Extended Linux Lane -An extended Linux lane, suitable for merge queue or scheduled execution, should include: - -- TUI waiting-state CPU gate -- larger 100 and 500 entity broker fixtures -- representative workflow execution checks -- launcher cold and warm backend checks -- model-gateway and secrets overhead checks -- dependency-fetch cold-cache, warm-cache, coalescing, and materialization checks -- audit and project-substrate heavier checks - -### macOS And Windows -Run the same flow families where feasible, but initially use them as: - -- smoke gates for correctness of the harness -- trend collection for later threshold tuning -- divergence detection if one platform regresses sharply relative to its own baseline - -Linux remains the first authoritative numeric gate until platform-specific noise and baselines are validated. +The first required lane should prefer metrics that are already stable enough on shared hosted Linux. The design may later promote selected higher-noise gates to a tighter authoritative Linux environment, but the initial gate set should not depend on that tighter environment existing on day one. ### Baseline Storage And Review -- Store benchmark baselines and threshold declarations in reviewed repo artifacts. +- Store benchmark baselines and threshold declarations in reviewed performance-contract artifacts separate from `runecontext/assurance/baseline.yaml`. - Do not auto-rewrite performance baselines inside normal CI runs. - Threshold changes should require an intentional doc-and-code review path, just like other product contract changes. -## Recommended Optimization Priorities Informed By The Findings -This change mostly records follow-on work rather than implementing it, except for the narrow alpha.7 waiting-state repaint reduction and focused benchmark coverage already landed. The remaining priorities implied by the evidence are: +## Explicit Deferrals +The following belong to the post-MVP expansion lane, not this MVP gate set: + +- broader CHG-049 workflow-pack surfaces beyond the supported beta workflow slice +- git-gateway and broader project-substrate performance suites that are not part of the beta hard gate +- larger broker-fixture ladders and heavier extended-lane measurements beyond the first release-defining fixtures +- tuned macOS and Windows numeric gates and wider cross-platform parity work -1. Treat long-lived waiting states differently from visibly progressing states so they do not pay the same `120ms` animation cost. -2. Reduce repeated `ShellSurface()` and layout recomputation in the TUI render path. -3. Narrow live-activity fan-out and discoverability-refresh work where the active route does not need it. -4. Cache or reuse expensive palette, measurement, and wrap work when semantic inputs have not changed. -5. Reevaluate default mouse cell-motion capture if click and wheel handling are sufficient for the intended route behavior. +Those remain tracked in `CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0`. ## Design Risks To Avoid - Do not create flaky performance gates that depend on live internet, external providers, or shared mutable host state. - Do not let performance verification introduce writes, mutable lockfiles, or auto-updated baselines into normal CI. - Do not overfit thresholds to a single developer workstation and then claim they are durable product budgets. -- Do not collapse empty-idle and active-waiting behavior into one TUI metric. +- Do not collapse empty-idle and waiting-state behavior into one TUI metric. +- Do not terminate metrics at advisory client-local or launcher-local milestones when authoritative persisted or broker-owned milestones exist downstream in the reviewed product contract. +- Do not use one universal statistics rule for every metric class when the metric semantics clearly differ. - Do not weaken trust-boundary or audit requirements in the name of performance. diff --git a/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/proposal.md b/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/proposal.md index 67e16a76..8a2fc771 100644 --- a/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/proposal.md +++ b/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/proposal.md @@ -1,8 +1,8 @@ ## Summary -Establish RuneCode's first explicit performance baselines and CI verification gates across the full product surface: TUI idle and active behavior, broker local API request and watch latency, runner and workflow execution paths, launcher backend startup, model-gateway and secrets overhead, audit and protocol verification, git gateway paths, and end-to-end attach and resume flows. +Establish RuneCode's first explicit MVP beta performance baselines and CI verification gates for the supported beta surface: TUI idle and waiting behavior, broker local API request and watch latency, supported workflow execution, launcher startup with the truthful attestation path, model-gateway and secrets overhead, dependency-fetch and offline-cache overhead, audit and protocol verification, external audit anchoring, and end-to-end attach or resume flows. ## Problem -RuneCode currently has correctness-oriented checks but no durable performance gate. That leaves the product vulnerable to regressions that remain invisible until they become user-facing lag, noisy CPU usage, or slow attach and workflow behavior. +RuneCode currently has correctness-oriented checks but no durable performance gate. That leaves the first usable beta surface vulnerable to regressions that remain invisible until they become user-facing lag, noisy CPU usage, or slow attach and workflow behavior. The immediate trigger for this change was a live TUI investigation driven by a user report that: @@ -11,37 +11,63 @@ The immediate trigger for this change was a live TUI investigation driven by a u - key and navigation latency felt slow even on fresh launch - lag appeared to worsen the longer the TUI stayed open -The investigation showed that RuneCode does not currently distinguish performance regimes clearly enough in planning or verification: +That investigation showed that RuneCode needs explicit performance regimes and deterministic verification for the actual MVP beta promise, especially: - empty-state TUI idle behavior -- active or waiting-session TUI behavior +- waiting-session TUI behavior +- attach and resume latency - broker watch and projection costs - local IPC request latency -- runner and workflow startup overhead -- backend startup and attach readiness -- provider, audit, protocol, and gateway overhead -- dependency-fetch cache miss, cache hit, and offline materialization overhead +- supported workflow startup and execution overhead +- launcher startup and attach readiness +- provider, audit, protocol, dependency-fetch, and external anchor overhead -Without deterministic fixtures, explicit thresholds, and CI enforcement, the project can regress in any of those areas without a visible review signal. +At the same time, the repository now has a broader set of performance surfaces than the MVP beta actually needs to hard-gate. If those broader surfaces remain inside the first gate set, the project risks either delaying beta or watering the gates down until they stop being useful. ## Proposed Change -- Record the alpha.7 TUI bootstrap already implemented from this investigation: waiting-state activity now stays visibly marked without reusing the fast `running` repaint loop, and `cmd/runecode-tui` now has focused render/update benchmarks for shell view, watch apply, and palette entry construction. +- Record the alpha.7 TUI bootstrap already implemented from this investigation: waiting-state activity now stays visibly marked without reusing the fast `running` repaint loop, and `cmd/runecode-tui` now has focused render and update benchmarks for shell view, watch apply, and palette entry construction. - Capture the corrected performance investigation results as product planning guidance rather than leaving them as temporary terminal-session notes. -- Define RuneCode performance regimes explicitly, especially the difference between: +- Define RuneCode MVP performance regimes explicitly, especially the difference between: - empty or quiescent local state - active or waiting session state - startup and attach latency - benchmarked render, watch, orchestration, and backend paths -- Introduce deterministic performance checks for all major RuneCode aspects, not just the TUI. +- Introduce deterministic performance checks for the supported beta surfaces only. - Assign per-aspect thresholds that are suitable for CI, with Linux-first numeric gates and deterministic local fixtures or stubs instead of live external dependencies. +- Define one reviewed performance-contract artifact family separate from `runecontext/assurance/baseline.yaml` so project-substrate assurance posture and performance-governance posture remain distinct. +- Store that reviewed performance-contract artifact family under `tools/perfcontracts/`, with `manifest.json`, per-surface contract files, reviewed fixture inventory, optional repeated-sample baseline artifacts, and a trusted check-only repo-local enforcement entrypoint that never rewrites baselines during normal CI. +- Freeze one metric taxonomy for the first gate set so each measurement uses the right contract model instead of one vague "benchmark" bucket: + - exact checks for exact counters and invariant counts + - absolute budgets for user-visible experience ceilings + - regression budgets for stable repeated microbenchmarks and hot paths + - hybrid budgets where both explicit product ceilings and baseline-regression limits matter +- Freeze one lane and activation taxonomy so initial required gates are honest about measurement authority and dependency readiness: + - `required_shared_linux` for stable required checks on shared hosted Linux + - `required_tight_linux` for checks that are required but too noisy for shared hosted Linux + - `informational_until_stable` for useful checks that need calibration before blocking + - `contract_pending_dependency` for suites whose contracts can be authored before the underlying reviewed path lands + - `extended` for heavier non-PR or post-MVP measurements +- Freeze the initial statistical defaults for the first implementation slice: + - repeated-sample robust comparison for microbenchmarks + - median plus `p95` plus explicit ceilings for latency metrics + - fixed-window repeated sampling with average or median plus max ceilings for CPU and process-behavior metrics + - exact comparison for deterministic event-count, payload-count, and duplicate-work metrics + - practical noise-floor thresholds in addition to statistical significance for repeated regression checks +- Freeze initial statistical constants before implementation starts: repeated microbenchmarks use at least `10` PR samples and preferably `20` baseline-refresh samples; cheap local latency metrics use enough samples for meaningful `p95` gates, with `30` trials as the default target; heavier lifecycle metrics may use median plus max ceilings until they are cheap enough for meaningful percentile gates; CPU and process-behavior metrics use explicit warmup plus fixed observation windows. +- Freeze the rule that performance timing boundaries must terminate on reviewed broker-owned or persisted milestones rather than advisory launcher-local or client-local heuristics whenever authoritative downstream milestones exist. +- Require each performance contract to declare `start_event`, `end_event`, `clock_source`, `evidence_source`, `included_phases`, `threshold_origin`, and stable fixture identifiers before the gate can become required. - Keep performance verification check-only and CI-safe so it remains compatible with `just ci` discipline and does not introduce silent writes or mutable benchmark artifacts during normal verification. - Freeze a policy for baseline maintenance so future work can tighten thresholds intentionally instead of letting them drift implicitly. -- Include dependency-fetch and offline-cache performance as a first-class product regime, including cache miss, cache hit, miss coalescing, bounded concurrency, stream-to-CAS persistence, and broker-mediated offline dependency staging/materialization costs. -- Include explicit measurement of the refined CHG-050 workflow path, including definition validation/canonicalization, trusted compilation, compiled-plan persistence/load, and runner startup from immutable `RunPlan`. -- Include explicit measurement of the CHG-049 first-party workflow-pack surfaces, including draft artifact generation, explicit draft promote/apply, reviewed implementation-input-set validation/binding, direct CLI workflow triggering, repo-scoped admission control/idempotency, and fail-closed drift-triggered re-evaluation or recompilation costs. -- Preserve one topology-neutral performance program across constrained local hardware and larger deployments; tuning may differ, but performance work must not imply separate architecture paths or trust models. +- Include dependency-fetch and offline-cache performance as an MVP product regime, including cache miss, cache hit, miss coalescing, bounded concurrency, stream-to-CAS persistence, and broker-mediated offline dependency staging or materialization costs. +- Include explicit measurement of the refined CHG-050 workflow path, including definition validation or canonicalization, trusted compilation, compiled-plan persistence or load, and runner startup from immutable `RunPlan`. +- Include explicit measurement of the supported CHG-049 first-party workflow-pack beta slice rather than every broader workflow-pack surface. - Include explicit measurement of the required attestation path for supported runtime startup and attach flows, including cold verification, warm verification-cache hits, replay and freshness checks, and persisted attestation-evidence handling. - Include explicit measurement of the external audit anchoring path, including prepare latency, execute handoff latency, deferred completion handling, target-proof admission cost, and verifier behavior on unchanged verified seals. +- Freeze one small reviewed MVP fixture inventory per major surface for the first gate set rather than starting with broad ladder coverage; larger fixture ladders remain post-MVP expansion work. +- Assign stable fixture IDs for the first reviewed inventory before collecting baselines so future fixture expansion does not churn existing metric identity. +- Treat dependency-fetch memory posture as an explicit architectural contract, measured through both coarse process memory observations and reviewed internal bounded-buffer instrumentation so stream-to-CAS behavior is verified directly rather than inferred only from RSS. +- Keep the first required Linux performance lane compatible with shared CI where thresholds are conservative enough to remain deterministic, while leaving room to promote selected high-noise metrics to a tighter authoritative Linux environment later without changing product architecture or metric identity. +- Defer broader workflow-pack surfaces, git-gateway performance expansion, larger fixture ladders, and tuned macOS or Windows numeric gates to `CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0`. ## Why Now RuneCode is approaching the first usable end-to-end Linux-first cut. That makes performance regressions more dangerous because users are no longer exercising isolated demos; they are exercising a connected product composed of the TUI, broker, runner, gateway, audit, and isolate layers. @@ -49,42 +75,51 @@ RuneCode is approaching the first usable end-to-end Linux-first cut. That makes The corrected TUI investigation also showed that the product needs a more precise narrative than "the TUI is slow": - empty-state idle is already near the expected low CPU range when the broker store is truly isolated -- active or waiting work can still drive unacceptable sustained repaint cost and should be gated separately +- waiting work can still drive unacceptable sustained repaint cost and should be gated separately -Capturing that distinction now prevents future work from overfitting to the wrong problem statement and gives the project one durable performance-verification plan before more release-hardening work lands. +Capturing that distinction now prevents future work from overfitting to the wrong problem statement and gives the project one durable performance-verification plan for the MVP beta before more release-hardening work lands. ## Assumptions -- Linux CI will remain the first authoritative numeric-gate environment for the initial performance program. -- macOS and Windows should still execute the same flows where feasible, but their initial role is smoke, trend, and divergence detection until platform-specific baselines are tuned. -- Deterministic local fixtures, synthetic stores, local bare remotes, and stubbed provider backends are acceptable and preferred for CI gating. +- Linux CI will remain the first authoritative numeric-gate environment for the initial MVP performance program. +- Shared hosted Linux CI is acceptable for the first required numeric-gate slice where thresholds are deliberately conservative and metric noise is understood; the design may later promote selected high-noise metrics to a tighter Linux environment without redefining the product contract. +- Deterministic local fixtures, synthetic stores, stubbed provider backends, and stubbed external anchor targets are acceptable and preferred for CI gating. - Network round-trip time to external providers is out of scope for hard CI gates; only RuneCode-added overhead should be measured under deterministic stubs. - Performance checks must not weaken trust boundaries, bypass audit or policy, or replace canonical broker-owned state with client-local shortcuts. -- Performance checks for external audit anchoring must not reward forbidden shortcuts such as performing network I/O under audit-ledger lock, bypassing authoritative verifier admission, or replacing incremental receipt admission with a hidden trust-reducing fast path. -- Performance verification is a product-quality concern and should remain part of the normal release and roadmap conversation, not a one-off local debugging artifact. +- The supported beta workflow slice is the right first workflow gate set; broader workflow-pack entry families and post-MVP workflow surfaces should be measured later rather than broadening the first beta gate set prematurely. +- The first performance implementation slice should start with the reviewed statistical defaults captured by this change and tune them only after implementation and validation data shows that a given metric class needs different handling. +- The first implementation should treat threshold origins explicitly as `product_budget`, `investigation_baseline`, `first_calibration`, or `temporary_guardrail` so later reviewers can distinguish product promises from provisional calibration values. +- External audit anchoring and truthful post-handshake attestation performance suites should align explicitly with the underlying reviewed changes they measure rather than relying on sequencing folklore. +- Harnesses and performance contracts may be defined before dependent paths are complete, but gates must not become required until their `activation_state` is no longer `contract_pending_dependency` and the reviewed path exists. +- Broader macOS and Windows numeric performance gates should follow later platform tuning work rather than blocking Linux-first beta readiness. ## Out of Scope - Broad follow-on optimization work beyond the small alpha.7 waiting-state fix and focused benchmark coverage already landed. +- Broad workflow-pack performance expansion beyond the supported beta slice. +- Git-gateway performance gating when that surface is not part of the MVP beta hard gate. +- Larger broker-fixture ladders and heavier extended-lane checks that are valuable but not release-defining for the first beta. +- Tuned macOS and Windows numeric gates. - Publishing external provider SLA promises based on networked measurements. - Replacing Bubble Tea, Lip Gloss, the broker architecture, or the runner architecture solely to satisfy this planning change. - Treating one local developer machine profile as authoritative for every threshold. - Adding CI steps that mutate repo state, rewrite baselines automatically, or depend on ambient external services. ## Impact -This change gives RuneCode one canonical planning surface for: +This change gives RuneCode one canonical planning surface for the first durable performance contract of the MVP beta: - the corrected TUI performance findings -- the distinction between empty-idle and active-state costs -- the profile-backed hot paths that deserve follow-on optimization work -- the deterministic benchmark and latency checks needed across the entire product +- the distinction between empty-idle and waiting-state costs +- the deterministic benchmark and latency checks needed across the supported beta surface - the per-aspect thresholds and CI structure required to make performance a maintained contract rather than an anecdotal concern +- the explicit metric taxonomy, statistical defaults, and practical noise-floor policy for the first durable gate set +- the explicit rule that reviewed broker-owned or persisted milestones are authoritative timing boundaries for performance checks +- the explicit separation between project-substrate assurance baseline state and reviewed performance-contract artifacts +- the explicit lane-state, activation-state, fixture-ID, and threshold-provenance model needed to make the performance program executable without rewarding shortcuts or creating flaky gates - the explicit expectation that dependency-fetch performance must be measured on both cold-cache and warm-cache paths without weakening trust boundaries or buffering full dependency payloads in memory -- the explicit expectation that first-party workflow-pack entry, draft, promote/apply, approved-input binding, and re-evaluation paths become measurable product surfaces rather than invisible orchestration overhead +- the explicit expectation that the supported first-party workflow slice becomes a measurable product surface rather than invisible orchestration overhead - the explicit expectation that launcher and attach-ready performance checks measure the reviewed signed-runtime plus required-attestation trust path rather than rewarding bypasses around attestation verification, replay or freshness enforcement, or attestation evidence persistence It also freezes one durable product-level rule for future work: - RuneCode performance should be evaluated per subsystem and per runtime regime, not as one vague "fast enough" claim for the whole product -- The same broker-owned workflow architecture should be measured and optimized across environments rather than replaced with different contract or authority paths for small-device versus scaled deployment shapes - -The broader project-wide performance gates, real-child CPU harnesses, and subsystem-specific CI thresholds remain deferred to this later change and are not part of the alpha.7 implementation slice. +The broader post-MVP performance expansion remains tracked separately in `CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0`. diff --git a/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/references.md b/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/references.md index 90aea6bd..373e02cc 100644 --- a/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/references.md +++ b/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/references.md @@ -39,6 +39,9 @@ - Lip Gloss: https://github.com/charmbracelet/lipgloss - Go `pprof`: https://pkg.go.dev/runtime/pprof - Go benchmarking: https://pkg.go.dev/testing +- Go `benchstat`: https://pkg.go.dev/golang.org/x/perf/cmd/benchstat +- Criterion analysis guide: https://bheisler.github.io/criterion.rs/book/analysis.html +- LLVM benchmarking tips: https://llvm.org/docs/Benchmarking.html ## Related Changes @@ -48,12 +51,14 @@ - `runecontext/changes/CHG-2026-011-7240-secretsd-model-gateway-v0/` - `runecontext/changes/CHG-2026-012-f1ef-workflow-runner-workspace-roles-deterministic-gates-v0/` - `runecontext/changes/CHG-2026-013-d2c9-minimal-tui-v0/` +- `runecontext/changes/CHG-2026-025-5679-external-audit-anchoring-v0/` - `runecontext/changes/CHG-2026-037-91be-tui-multi-session-power-workspace-v0/` - `runecontext/changes/CHG-2026-043-8e9b-live-activity-watch-streams-v0/` - `runecontext/changes/CHG-2026-047-c3e2-local-control-plane-bootstrap-persistent-session-lifecycle-v0/` - `runecontext/changes/CHG-2026-048-6b7a-session-execution-orchestration-v0/` - `runecontext/changes/CHG-2026-049-1d4e-first-party-runecontext-workflow-pack-v0/` - `runecontext/changes/CHG-2026-050-e3f8-workflow-definition-contract-binding-v0/` +- `runecontext/changes/CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0/` ## Planning Notes @@ -63,3 +68,8 @@ - After the alpha.7 waiting-state split landed, a fresh isolated rerun measured empty-state CPU at `0.20-0.80%` and waiting-state CPU at `0.00-1.00%` for the real `runecode-tui` child. - The strongest before/after comparison is the waiting-state path: the earlier sample climbed through `22.81%` and `61.92%` CPU, while the post-fix isolated waiting sample stayed at `1.00%` mid and aged CPU. - The post-fix waiting transcript still rendered `WAITING session=sess-manual-multiwait`, confirming the improvement came from removing the fast repaint loop for waiting states rather than from hiding the state cue. +- The first durable gate set should use a dedicated reviewed performance-contract artifact family rather than overloading `runecontext/assurance/baseline.yaml`, which remains part of project-substrate assurance posture. +- The first implementation slice should use reviewed statistical defaults per metric class: repeated-sample robust comparison for microbenchmarks, median plus `p95` plus explicit ceilings for latency metrics, fixed-window repeated sampling with average or median plus max guardrails for CPU/process-behavior metrics, and exact comparison for deterministic invariant counts. +- Performance timing boundaries should terminate on reviewed broker-owned or persisted milestones whenever those authoritative surfaces exist downstream in the product contract. +- Follow-up review refined the implementation foundation further: performance contracts should live under `tools/perfcontracts/`, use stable fixture IDs, declare lane authority and activation state, declare threshold provenance, and include timing-boundary metadata before required enforcement. +- Shared hosted Linux remains acceptable for stable required checks, while high-noise checks should start informational, remain pending dependency, or move to a tighter Linux authority without changing RuneCode's product architecture. diff --git a/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/standards.md b/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/standards.md index 95cef75a..04eb188a 100644 --- a/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/standards.md +++ b/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/standards.md @@ -10,16 +10,25 @@ - `standards/security/trusted-runtime-evidence-and-broker-projection.md` ## Resolution Notes -This change exists to make RuneCode performance a maintained product contract rather than a one-off local debugging exercise. +This change exists to make RuneCode MVP beta performance a maintained product contract rather than a one-off local debugging exercise. -That includes freezing the following clarifications for future work: +That includes freezing the following clarifications for the first gate set: - performance verification must remain deterministic, reviewable, and CI-safe - performance checks must respect the same trust boundaries and broker-owned authority surfaces as correctness checks -- TUI empty-idle and active or waiting-state costs are distinct product regimes and must not be collapsed into one metric -- broker request latency, watch-family cost, runner startup, workflow execution, launcher startup, gateway overhead, audit verification, and attach or resume paths all need explicit budgets -- first-party workflow-pack entry, draft artifact generation, explicit promote/apply, approved-input binding, admission control, and fail-closed re-evaluation paths also need explicit budgets once CHG-049 lands -- Linux is the first authoritative numeric gate, while other platforms should execute the same flow families and gain tuned thresholds over time +- TUI empty-idle and waiting-state costs are distinct product regimes and must not be collapsed into one metric +- broker request latency, watch-family cost, runner startup, supported workflow execution, launcher startup, gateway overhead, audit verification, external audit anchoring, and attach or resume paths all need explicit budgets +- performance-contract artifacts remain separate from project-substrate assurance baseline state; `runecontext/assurance/baseline.yaml` is not the home for CHG-053 threshold declarations +- performance-contract artifacts live under `tools/perfcontracts/` and are enforced by a trusted check-only repo tool rather than rewritten by CI +- the first gate set uses an explicit metric taxonomy across exact, absolute-budget, regression-budget, and hybrid-budget checks rather than one generic benchmark bucket +- every metric needs reviewed lane authority, activation state, stable fixture identity, threshold provenance, and timing-boundary metadata before required enforcement +- timing boundaries must terminate on reviewed broker-owned or persisted milestones whenever those authoritative surfaces exist downstream in the product contract +- the first implementation slice uses reviewed statistical defaults per metric class rather than one universal statistics rule for every check +- the first implementation slice freezes sample-count, warmup, p95-eligibility, and practical-noise-floor constants before required gates are enforced +- the first gate set should start with one small reviewed fixture inventory per major surface, while larger fixture ladders remain explicit post-MVP expansion work +- contracts for attestation and external audit anchoring may be authored before their reviewed dependency paths land, but required enforcement must wait until those paths exist +- the supported first-party workflow-pack beta slice needs explicit budgets now, while broader workflow-pack surfaces should be expanded later under `CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0` +- Linux is the first authoritative numeric gate for this layer, while broader cross-platform tuning should remain explicit post-MVP work - threshold updates and baseline refreshes require explicit review rather than silent CI mutation This change builds on the existing broker, runner, TUI, lifecycle, and watch-family foundations rather than redefining those product contracts locally. diff --git a/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/status.yaml b/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/status.yaml index 04d156dc..53493a89 100644 --- a/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/status.yaml +++ b/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/status.yaml @@ -1,7 +1,7 @@ schema_version: 1 id: CHG-2026-053-9d2b-performance-baselines-verification-gates-v0 title: Project Performance Baselines + Verification Gates v0 -status: planned +status: implemented type: feature size: large verification_status: pending @@ -10,8 +10,13 @@ context_bundles: - ci-tooling related_specs: [] related_decisions: [] -related_changes: [] +related_changes: + - CHG-2026-025-5679-external-audit-anchoring-v0 + - CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0 + - CHG-2026-060-c1a4-beta-readiness-hardening-product-polish + - CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0 depends_on: + - CHG-2026-025-5679-external-audit-anchoring-v0 - CHG-2026-008-62e1-broker-local-api-v0 - CHG-2026-011-7240-secretsd-model-gateway-v0 - CHG-2026-012-f1ef-workflow-runner-workspace-roles-deterministic-gates-v0 @@ -21,6 +26,7 @@ depends_on: - CHG-2026-048-6b7a-session-execution-orchestration-v0 - CHG-2026-049-1d4e-first-party-runecontext-workflow-pack-v0 - CHG-2026-050-e3f8-workflow-definition-contract-binding-v0 + - CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0 informed_by: - CHG-2026-009-1672-launcher-microvm-backend-v0 - CHG-2026-037-91be-tui-multi-session-power-workspace-v0 diff --git a/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/tasks.md b/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/tasks.md index 05a191a6..767df1f4 100644 --- a/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/tasks.md +++ b/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/tasks.md @@ -2,94 +2,102 @@ ## Phase 1: Deterministic Fixture Foundation -- [ ] Add deterministic fixture builders for empty, waiting, medium, and large broker stores. -- [ ] Add deterministic runner and workflow fixtures that do not require live external dependencies. -- [ ] Add deterministic stubbed provider backends for model-gateway and secrets overhead checks. -- [ ] Add deterministic local bare-remote fixtures for git gateway execution checks. -- [ ] Define one reviewed baseline-artifact format for benchmark and latency thresholds. +- [x] Add deterministic fixture builders for empty and waiting broker stores used by the supported beta path. +- [x] Add deterministic runner and supported-workflow fixtures that do not require live external dependencies. +- [x] Add deterministic stubbed provider backends for model-gateway and secrets overhead checks. +- [x] Add deterministic stubbed external-anchor targets for prepare, execute, deferred, and receipt-admission checks. +- [x] Define one reviewed performance-contract artifact format for benchmark and latency thresholds, separate from `runecontext/assurance/baseline.yaml`. +- [x] Store the reviewed performance-contract family under `tools/perfcontracts/` with a manifest, per-surface contract files, reviewed fixture inventory, and optional repeated-sample baseline artifacts where needed. +- [x] Define one trusted repo-local compare/enforce tool under `tools/` that reads performance contracts and check outputs without rewriting baselines during normal CI. +- [x] Define the metric taxonomy for exact, absolute-budget, regression-budget, and hybrid-budget checks in the reviewed performance-contract artifacts. +- [x] Define lane authority and activation states for every metric: `required_shared_linux`, `required_tight_linux`, `informational_until_stable`, `contract_pending_dependency`, and `extended`. +- [x] Define the initial reviewed MVP fixture inventory per major surface and explicitly defer larger fixture ladders to `CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0`. +- [x] Assign stable fixture IDs for the initial inventory before collecting baselines. ## Phase 2: TUI Regime Checks -- [ ] Add a real-child CPU sampler for PTY-launched `runecode-tui` suitable for CI. -- [ ] Add an empty-state idle CPU gate using fully isolated broker store, audit ledger, runtime directory, socket, and TUI target alias. -- [ ] Add a waiting-state CPU gate using a deterministic waiting-session fixture. -- [ ] Add attach/startup and key-response latency checks for quiet and waiting-state fixtures. +- [x] Add a real-child CPU sampler for PTY-launched `runecode-tui` suitable for CI. +- [x] Add an empty-state idle CPU gate using fully isolated broker store, audit ledger, runtime directory, socket, and TUI target alias. +- [x] Add a waiting-state CPU gate using a deterministic waiting-session fixture. +- [x] Add attach/startup and key-response latency checks for quiet and waiting-state fixtures. +- [x] Freeze authoritative timing boundaries for TUI attach and key-response checks, including `start_event`, `end_event`, `clock_source`, `evidence_source`, and `included_phases`. - [x] Add `go test -bench` coverage for render and update hot paths, including shell view, watch apply, and palette-entry building. Alpha.7 bootstrap already landed: - [x] Distinguish waiting activity from actively running work in shell projection and shell chrome so waiting sessions and runs stay visible without reusing the fast running animation loop. -## Phase 3: Broker Local API And Watch Checks - -- [ ] Add deterministic latency checks for broker unary local API requests across 10, 100, and 500 entity fixtures. -- [ ] Add deterministic latency and payload-growth checks for `run-watch`, `approval-watch`, `session-watch`, and `session-turn-execution-watch`. -- [ ] Add control-plane mutation latency checks for execution trigger, continue, approval resolve, and backend posture change paths. -- [ ] Ensure all broker performance checks remain local-only and do not rely on live network services. - -## Phase 4: Runner And Workflow Checks - -- [ ] Add wall-time and regression checks for runner boundary verification and protocol fixture tests. -- [ ] Add a representative runner cold-start check with a deterministic minimal workflow. -- [ ] Add no-op and small deterministic workflow execution performance checks. -- [ ] Add deterministic checks for CHG-050 workflow-definition/process-definition validation, canonicalization, and trusted compilation overhead. -- [ ] Add deterministic checks for compiled `RunPlan` persistence/load and runner startup from immutable `RunPlan`. -- [ ] Add deterministic draft artifact-generation checks for the CHG-049 first-party workflow pack. -- [ ] Add deterministic draft promote/apply checks for canonical RuneContext mutation through the shared audited path. -- [ ] Add deterministic reviewed implementation-input-set validation/binding checks for approved-change implementation entry. -- [ ] Add deterministic direct CLI workflow-trigger latency checks for first-party workflow-pack entry. -- [ ] Add deterministic repo-scoped admission-control and idempotency checks for first-party workflow trigger paths. -- [ ] Add deterministic fail-closed re-evaluation/recompile checks for project-context or approved-input drift on first-party workflow-pack paths. -- [ ] Add attach and resume performance checks for persistent local control-plane lifecycle behavior. - -## Phase 5: Launcher, Gateway, Audit, And Protocol Checks - -- [ ] Add cold and warm microVM startup checks, with cold covering verified-cache miss or trusted-admission cost and warm covering verified local runtime-asset cache-hit cost on the same signed runtime identity. -- [ ] Add cold and warm container startup checks for the explicit opt-in backend, with the same verified-cache miss or hit semantics used for microVM startup checks. -- [ ] Add deterministic model-gateway invoke-overhead and secret-ingress checks using stubbed provider backends. -- [ ] Add deterministic dependency-fetch cache-miss checks using reviewed typed dependency-request fixtures and stubbed public-registry payload sources. -- [ ] Add deterministic dependency-fetch cache-hit checks for already-cached resolved dependency units. -- [ ] Add miss-coalescing checks so identical concurrent dependency requests do not multiply upstream fetch work. -- [ ] Add broker-mediated offline dependency staging or materialization checks for workspace consumption. -- [ ] Add streaming and memory-budget checks to ensure dependency cache fill stays stream-to-CAS rather than full-payload buffering. -- [ ] Add audit verification and finalize-verify runtime checks for standard and larger fixture ledgers. -- [ ] Add protocol schema and fixture-parity performance checks. -- [ ] Add git gateway prepare and local execute checks plus project-substrate posture and preview or apply checks. -- [ ] Add deterministic external audit anchor prepare checks against stubbed target descriptors and pre-sealed audit segments. -- [ ] Add deterministic external audit anchor execute checks for both fast-completed and deferred-completion paths. -- [ ] Add deferred-completion visibility checks for external audit anchoring through durable get or watch surfaces. -- [ ] Add external anchor receipt-admission checks for unchanged verified seals so the incremental path is measured explicitly. -- [ ] Add invalid-proof and unavailable-target external anchor checks so degraded and failed posture costs are measured explicitly. -- [ ] Add checks ensuring external audit anchoring performance does not reward network I/O under audit-ledger lock or bypass authoritative verifier admission. +## Phase 3: Broker, Attach, And Resume Checks + +- [x] Add deterministic latency checks for broker unary local API requests used by the supported beta surface. +- [x] Add deterministic latency and payload-growth checks for `run-watch`, `approval-watch`, `session-watch`, and `session-turn-execution-watch` on the supported beta fixtures. +- [x] Add control-plane mutation latency checks for execution trigger, continue, approval resolve, and backend posture change paths. +- [x] Add attach and resume performance checks for the persistent local control-plane lifecycle. +- [x] Ensure all broker performance checks remain local-only and do not rely on live network services. +- [x] Freeze authoritative timing boundaries for local attach and resume, including `start_event`, `end_event`, `clock_source`, `evidence_source`, and `included_phases`. + +## Phase 4: Runner, Workflow, Launcher, And Attestation Checks + +- [x] Add wall-time and regression checks for runner boundary verification and protocol fixture tests. +- [x] Add a representative runner cold-start check with a deterministic minimal workflow. +- [x] Add deterministic checks for the supported MVP workflow execution path. +- [x] Add deterministic checks for CHG-050 workflow-definition/process-definition validation, canonicalization, and trusted compilation overhead. +- [x] Add deterministic checks for compiled `RunPlan` persistence/load and runner startup from immutable `RunPlan`. +- [x] Add deterministic checks for the supported CHG-049 first-party workflow-pack beta slice only. +- [x] Add cold and warm microVM startup checks, with cold covering verified-cache miss or trusted-admission cost and warm covering verified local runtime-asset cache-hit cost on the same signed runtime identity. +- [x] Add cold and warm container startup checks for the explicit opt-in backend, with the same verified-cache miss or hit semantics used for microVM startup checks. +- [x] Add attestation cold-path and warm verification-cache checks for the truthful supported runtime path. +- [x] Freeze authoritative timing boundaries for launcher and attestation checks, including `start_event`, `end_event`, `clock_source`, `evidence_source`, and `included_phases`. +- [x] Keep attestation performance contracts in `contract_pending_dependency` until `CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0` lands. + +## Phase 5: Gateway, Dependency, Audit, Protocol, And External Anchor Checks + +- [x] Add deterministic model-gateway invoke-overhead and secret-ingress checks using stubbed provider backends. +- [x] Add deterministic dependency-fetch cache-miss checks using reviewed typed dependency-request fixtures and stubbed public-registry payload sources. +- [x] Add deterministic dependency-fetch cache-hit checks for already-cached resolved dependency units. +- [x] Add miss-coalescing checks so identical concurrent dependency requests do not multiply upstream fetch work. +- [x] Add broker-mediated offline dependency staging or materialization checks for workspace consumption. +- [x] Add streaming and memory-budget checks to ensure dependency cache fill stays stream-to-CAS rather than full-payload buffering. +- [x] Add reviewed bounded-buffer instrumentation for dependency cache fill so stream-to-CAS posture is verified directly in addition to coarse process memory observations. +- [x] Add audit verification and finalize-verify runtime checks for deterministic ledger fixtures. +- [x] Add protocol schema and fixture-parity performance checks. +- [x] Add deterministic external audit anchor prepare checks against stubbed target descriptors and pre-sealed audit segments. +- [x] Add deterministic external audit anchor execute checks for both fast-completed and deferred-completion paths. +- [x] Add deferred-completion visibility checks for external audit anchoring through durable get or watch surfaces. +- [x] Add external anchor receipt-admission checks for unchanged verified seals so the incremental path is measured explicitly. +- [x] Add checks ensuring external audit anchoring performance does not reward network I/O under audit-ledger lock or bypass authoritative verifier admission. +- [x] Freeze authoritative timing boundaries for external audit anchoring, including `start_event`, `end_event`, `clock_source`, `evidence_source`, and `included_phases`. +- [x] Keep external-audit-anchor performance contracts in `contract_pending_dependency` until `CHG-2026-025-5679-external-audit-anchoring-v0` lands. ## Phase 6: CI Integration -- [ ] Add a required Linux PR lane containing the smallest deterministic performance gates with the highest regression value. -- [ ] Add an extended Linux lane for larger fixtures, waiting-state TUI checks, launcher startup, and broader end-to-end measurements. -- [ ] Run the same flow families on macOS and Windows where feasible as smoke or trend collection until platform-specific numeric thresholds are tuned. -- [ ] Keep performance verification check-only and aligned with `just ci` discipline. +- [x] Add a required Linux PR lane containing the smallest deterministic performance gates with the highest regression value across the MVP beta surface. +- [x] Limit the initial required shared-Linux PR lane to metrics declared `required_shared_linux` and keep higher-noise metrics informational or pending until their authority is reviewed. +- [x] Keep performance verification check-only and aligned with `just ci` discipline. +- [x] Store reviewed threshold declarations in the dedicated performance-contract artifacts rather than auto-generated mutable baselines. +- [x] Distinguish metrics stable enough for shared hosted Linux required gates from metrics that may later need a tighter authoritative Linux environment. ## Phase 7: Baseline Governance -- [ ] Check in reviewed threshold declarations rather than auto-generated mutable baselines. -- [ ] Define the review process for tightening thresholds or accepting deliberate regressions with explicit justification. -- [ ] Document how to refresh baselines safely when major architectural shifts land. +- [x] Define the review process for tightening thresholds or accepting deliberate regressions with explicit justification. +- [x] Document how to refresh baselines safely when major architectural shifts land. +- [x] Document the reviewed statistical defaults for microbenchmarks, latency metrics, CPU/process-behavior metrics, and exact metrics. +- [x] Document initial statistical constants for sample counts, warmup windows, p95 eligibility, and repeated-window CPU/process metrics. +- [x] Document the practical noise-floor policy used alongside repeated-sample regression checks. +- [x] Document `threshold_origin` for every threshold as `product_budget`, `investigation_baseline`, `first_calibration`, or `temporary_guardrail`. +- [x] Document which broader performance surfaces are intentionally deferred to `CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0`. ## Acceptance Criteria -- [ ] RuneCode has explicit performance checks for all major product aspects, not just the TUI. -- [ ] The TUI has separate gates for empty-idle and waiting-state behavior. -- [ ] Broker local API requests and watch families have deterministic latency checks at multiple fixture sizes. -- [ ] Runner, workflow, launcher, model-gateway, audit, protocol, and git gateway paths each have at least one deterministic CI-compatible performance check. -- [ ] External audit anchoring prepare, execute, deferred completion, and receipt-admission paths each have at least one deterministic CI-compatible performance check. -- [ ] The refined CHG-050 workflow path has explicit checks for validation/canonicalization, trusted compilation, compiled-plan persistence/load, and runner startup from immutable `RunPlan`. -- [ ] The CHG-049 first-party workflow pack has explicit checks for draft artifact generation, explicit promote/apply, implementation-input-set validation/binding, direct CLI triggering, repo-scoped admission control/idempotency, and drift-triggered re-evaluation/recompile overhead. -- [ ] Dependency-fetch and offline-cache cold-cache, warm-cache, coalescing, and materialization paths each have at least one deterministic CI-compatible performance check. -- [ ] Linux PR CI enforces numeric thresholds for the highest-value checks. -- [ ] macOS and Windows execute the same performance flow families where feasible, at least as smoke or trend gates. -- [ ] Performance baselines assume one topology-neutral workflow/control-plane architecture across constrained and scaled environments rather than separate architecture paths. -- [ ] External audit anchoring baselines assume the same topology-neutral architecture and do not reward lock-held network I/O, trust-path bypasses, or full verifier replay as the only hot-path receipt-admission mechanism. -- [ ] Launcher startup thresholds measure the reviewed signed runtime-asset path and do not reward bypassing runtime-asset admission, verification, or launch-deny evidence generation. -- [ ] Launcher startup and attach-ready thresholds also measure the required attestation path and do not reward bypassing attestation verification, replay checks, freshness checks, or attestation evidence persistence. -- [ ] Attestation verification has explicit cold-path and warm verification-cache performance checks under immutable-identity cache semantics. -- [ ] Threshold changes and baseline refreshes require explicit review rather than silent CI mutation. +- [x] RuneCode has explicit deterministic performance checks for the supported MVP beta surfaces rather than only anecdotal local measurements. +- [x] The TUI has separate gates for empty-idle and waiting-state behavior. +- [x] Broker local API requests and watch families have deterministic latency checks for the supported beta fixtures. +- [x] Runner startup, the supported workflow path, launcher startup, and the truthful attestation path each have at least one deterministic CI-compatible performance check. +- [x] Model-gateway, dependency-fetch, audit, protocol, and external audit anchoring paths each have at least one deterministic CI-compatible performance check. +- [x] Linux PR CI enforces numeric thresholds for the highest-value checks. +- [x] Reviewed performance-contract artifacts remain separate from project-substrate assurance baseline artifacts. +- [x] Each required metric has reviewed lane authority, activation state, fixture ID, threshold origin, and timing-boundary metadata. +- [x] Timing boundaries for attach, workflow, launcher, attestation, dependency, and external-anchor checks terminate on reviewed broker-owned or persisted milestones rather than advisory shortcuts. +- [x] The first implementation slice uses the reviewed statistical defaults captured by this change and tunes them only through explicit follow-up review. +- [x] Threshold changes and baseline refreshes require explicit review rather than silent CI mutation. +- [x] Broader workflow-pack surfaces, git-gateway checks, larger fixture ladders, and tuned macOS or Windows numeric gates are explicitly deferred to `CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0`. diff --git a/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/verification.md b/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/verification.md index 7911865b..45694020 100644 --- a/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/verification.md +++ b/runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/verification.md @@ -11,7 +11,7 @@ - Confirmed the real `runecode-tui` child can be sampled directly rather than attributing CPU to the `script` PTY wrapper. - Confirmed the first live high-CPU sample was against non-empty repo-scoped broker state rather than a truly empty isolated broker store. - Confirmed the corrected empty-state isolated measurement stayed near `0.5-1.0%` CPU even after the TUI remained open for roughly a minute. -- Confirmed the earlier non-empty-state live sample climbed through `22.81%` and `61.92%` CPU while the shell reported active session state, making it an active or waiting-state sample rather than an empty-idle sample. +- Confirmed the earlier non-empty-state live sample climbed through `22.81%` and `61.92%` CPU while the shell reported active session state, making it a waiting or active-state sample rather than an empty-idle sample. - Confirmed focused profiles point more strongly to render, wrap, ANSI, regex, and palette or surface allocation cost than to a single broker request hot spot. - Confirmed the post-fix isolated empty-state rerun measured `0.20%` fresh CPU, `0.80%` mid CPU, and `0.80%` aged CPU for the real `runecode-tui` child. - Confirmed the post-fix isolated waiting-state rerun measured `0.00%` fresh CPU, `1.00%` mid CPU, and `1.00%` aged CPU for the real `runecode-tui` child. @@ -19,36 +19,45 @@ - Confirmed the post-fix waiting transcript still rendered a `WAITING session=sess-manual-multiwait` marker, so the CPU improvement did not come from suppressing the operator-visible waiting cue. ## Planned Automated Checks -- `go test ./cmd/runecode-tui -bench 'BenchmarkShell(View|Watch|BuildPaletteEntries)' -benchmem` +- `go test ./cmd/runecode-tui -bench 'Benchmark(ShellViewEmpty|ShellViewWaitingSession|ShellWatchApply|BuildPaletteEntries)' -benchmem` - deterministic PTY-based TUI empty-idle CPU gate - deterministic PTY-based TUI waiting-state CPU gate - deterministic broker unary local API latency suite - deterministic broker watch-family latency suite -- runner boundary and protocol performance suite -- launcher startup and attach-ready performance suite -- dependency-fetch cache miss, cache hit, coalescing, and materialization performance suite -- external audit anchoring prepare, execute, deferred completion, and receipt-admission performance suite -- audit, protocol, gateway, and project-substrate performance suites +- deterministic attach and resume latency checks +- deterministic supported-workflow and launcher performance suite +- deterministic dependency-fetch, audit, protocol, model-gateway, and external-anchor performance suites ## Verification Notes -- Confirm the change preserves the corrected distinction between empty-state and active-state TUI measurements. +- Confirm the change preserves the corrected distinction between empty-state and waiting-state TUI measurements. - Confirm the change records the most important methodological correction: socket isolation is not broker-store isolation. - Confirm the design captures the profile-backed render and allocation hot spots, not just the top-line CPU numbers. -- Confirm performance checks are proposed for all major RuneCode aspects rather than just the TUI. -- Confirm each major subsystem has an explicit threshold policy or regression budget. -- Confirm the refined CHG-050 workflow path is measured explicitly, including validation/canonicalization, trusted compilation, compiled-plan persistence/load, and runner startup from immutable `RunPlan`. -- Confirm the CHG-049 first-party workflow-pack path is measured explicitly, including draft artifact generation, draft promote/apply, implementation-input-set validation/binding, direct CLI triggering, repo-scoped admission control/idempotency, and fail-closed drift-triggered re-evaluation/recompile costs. +- Confirm performance checks are proposed for the supported MVP beta surfaces rather than the full eventual product surface. +- Confirm the change defines a reviewed performance-contract artifact family separate from `runecontext/assurance/baseline.yaml`. +- Confirm the reviewed performance-contract artifact family lives under `tools/perfcontracts/` with a manifest, per-surface contract files, reviewed fixture inventory, and optional repeated-sample baselines where needed. +- Confirm one trusted repo-local compare/enforce tool exists under `tools/` and does not rewrite baselines during normal CI. +- Confirm the design freezes a metric taxonomy across exact, absolute-budget, regression-budget, and hybrid-budget checks. +- Confirm every metric declares lane authority and activation state before enforcement. +- Confirm the design freezes the reviewed statistical defaults for repeated microbenchmarks, latency metrics, CPU/process-behavior metrics, and exact metrics. +- Confirm statistical constants are defined for sample counts, warmup windows, p95 eligibility, repeated CPU/process windows, and practical noise floors. +- Confirm repeated-sample regression checks use a practical noise-floor policy in addition to significance or interval-based comparison logic. +- Confirm authoritative timing boundaries declare `start_event`, `end_event`, `clock_source`, `evidence_source`, and `included_phases`, and terminate on reviewed broker-owned or persisted milestones rather than advisory launcher-local or client-local proxies when reviewed downstream authority surfaces exist. +- Confirm the initial reviewed fixture inventory uses stable fixture IDs before baselines are collected. +- Confirm thresholds declare `threshold_origin` as `product_budget`, `investigation_baseline`, `first_calibration`, or `temporary_guardrail`. +- Confirm the refined CHG-050 workflow path is measured explicitly, including validation or canonicalization, trusted compilation, compiled-plan persistence/load, and runner startup from immutable `RunPlan`. +- Confirm the supported CHG-049 workflow-pack beta slice is measured explicitly while broader workflow-pack surfaces are deferred. - Confirm dependency-fetch and offline-cache have explicit cold-cache, warm-cache, miss-coalescing, and materialization checks. -- Confirm dependency-fetch performance checks preserve the reviewed stream-to-CAS and bounded-memory posture rather than rewarding trust-boundary shortcuts. +- Confirm dependency-fetch performance checks preserve the reviewed stream-to-CAS and bounded-memory posture rather than rewarding trust-boundary shortcuts, and that bounded-buffer instrumentation exists in addition to coarse process-memory observation. - Confirm external audit anchoring has explicit checks for prepare latency, execute-completed latency, execute-deferred handoff latency, deferred completion visibility, and receipt admission over an unchanged verified seal. - Confirm external audit anchoring performance checks do not reward forbidden shortcuts such as network I/O under audit-ledger lock, bypassing authoritative verifier admission, or forcing full verifier replay as the only normal receipt-admission path. -- Confirm launcher cold and warm startup checks are defined in terms of the signed runtime-asset path, with cold covering verified-cache miss or trusted admission and warm covering verified local cache hits. -- Confirm launcher performance checks do not reward bypassing runtime-asset admission, signer verification, component-digest checks, or launch-deny evidence generation. -- Confirm attestation cold and warm checks are defined in terms of the required attestation trust path, with cold covering full verification and warm covering immutable verification-cache hits. +- Confirm launcher cold and warm startup checks are defined in terms of the signed runtime-asset path. +- Confirm attestation cold and warm checks are defined in terms of the required attestation trust path. +- Confirm attestation performance contracts remain `contract_pending_dependency` until `CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0` lands. - Confirm launcher and attach-ready performance checks do not reward bypassing attestation verification, replay checks, freshness checks, or attestation evidence persistence. -- Confirm Linux remains the first authoritative numeric gate while other platforms still execute the same flow families where feasible. -- Confirm the change preserves one topology-neutral performance program across constrained local and larger deployments rather than implying separate architecture paths. -- Confirm the roadmap places this work under `v0.1.0-beta.1`. +- Confirm external-audit-anchor performance contracts remain `contract_pending_dependency` until `CHG-2026-025-5679-external-audit-anchoring-v0` lands. +- Confirm the first required Linux lane is scoped to metrics stable enough for shared hosted Linux thresholds, while leaving room to promote selected higher-noise metrics later without changing metric identity or product architecture. +- Confirm the roadmap places this work under `v0.1.0-alpha.11`. +- Confirm broader workflow-pack expansion, git-gateway expansion, larger fixture ladders, and tuned macOS or Windows numeric gates are deferred to `CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0`. - Confirm the change keeps performance verification check-only and CI-safe. ## Close Gate diff --git a/runecontext/changes/CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0/proposal.md b/runecontext/changes/CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0/proposal.md index 541c7e0b..ab7af7cf 100644 --- a/runecontext/changes/CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0/proposal.md +++ b/runecontext/changes/CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0/proposal.md @@ -28,7 +28,7 @@ This should land as a beta hardening follow-up rather than being folded into the The current lane fixed portability and strengthened shared session-binding inputs, but wiring real runtime-side boot/bind proof into launch gating would widen across launcher lifecycle, handshake sequencing, evidence persistence, audit timing, and broker projection. That is a separate product change, not a small patch. -Scheduling it for `v0.1.0-beta.1` keeps the work visible and reviewed before the first beta assurance story is treated as settled. +Scheduling it for `v0.1.0-alpha.11` keeps the work visible and reviewed in the explicit pre-beta hardening lane before the first beta assurance story is treated as settled. ## Assumptions - The reviewed secure-session contract in trusted Go remains the authoritative validation boundary for runtime-side session proof. diff --git a/runecontext/changes/CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0/status.yaml b/runecontext/changes/CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0/status.yaml index a43ccd93..c2feed2c 100644 --- a/runecontext/changes/CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0/status.yaml +++ b/runecontext/changes/CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0/status.yaml @@ -1,7 +1,7 @@ schema_version: 1 id: CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0 title: Runtime Attestation Post-Handshake Gating v0 -status: planned +status: implemented type: feature size: medium verification_status: pending @@ -14,6 +14,8 @@ related_changes: - CHG-2026-026-98be-image-toolchain-signing-pipeline - CHG-2026-030-98b8-isolate-attestation-v0 - CHG-2026-047-c3e2-local-control-plane-bootstrap-persistent-session-lifecycle-v0 + - CHG-2026-053-9d2b-performance-baselines-verification-gates-v0 + - CHG-2026-060-c1a4-beta-readiness-hardening-product-polish depends_on: - CHG-2026-009-1672-launcher-microvm-backend-v0 - CHG-2026-026-98be-image-toolchain-signing-pipeline diff --git a/runecontext/changes/CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0/verification.md b/runecontext/changes/CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0/verification.md index a84dd368..b9679b6c 100644 --- a/runecontext/changes/CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0/verification.md +++ b/runecontext/changes/CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0/verification.md @@ -7,7 +7,7 @@ ## Verification Notes - Confirm the change keeps RuneContext change docs as the canonical planning surface. -- Confirm the roadmap entry is added under `v0.1.0-beta.1` and remains outcome-focused. +- Confirm the roadmap entry is added under `v0.1.0-alpha.11` and remains outcome-focused. - Confirm the design preserves the reviewed trust ordering from isolate attestation instead of redefining the attestation model. - Confirm `attested` is described as unavailable before secure-session validation and post-handshake trusted verification. - Confirm the change does not redefine runtime identity away from the signed runtime-asset pipeline. diff --git a/runecontext/changes/CHG-2026-055-546a-verification-evidence-preservation-bundle-export-v0/status.yaml b/runecontext/changes/CHG-2026-055-546a-verification-evidence-preservation-bundle-export-v0/status.yaml index a1f342f0..6f6e0a87 100644 --- a/runecontext/changes/CHG-2026-055-546a-verification-evidence-preservation-bundle-export-v0/status.yaml +++ b/runecontext/changes/CHG-2026-055-546a-verification-evidence-preservation-bundle-export-v0/status.yaml @@ -1,7 +1,7 @@ schema_version: 1 id: CHG-2026-055-546a-verification-evidence-preservation-bundle-export-v0 title: Verification Evidence Preservation + Bundle Export v0 -status: proposed +status: implemented type: feature size: large verification_status: pending diff --git a/runecontext/changes/CHG-2026-056-8c75-audit-evidence-index-record-inclusion-v0/status.yaml b/runecontext/changes/CHG-2026-056-8c75-audit-evidence-index-record-inclusion-v0/status.yaml index 885eb6d5..e411304f 100644 --- a/runecontext/changes/CHG-2026-056-8c75-audit-evidence-index-record-inclusion-v0/status.yaml +++ b/runecontext/changes/CHG-2026-056-8c75-audit-evidence-index-record-inclusion-v0/status.yaml @@ -1,7 +1,7 @@ schema_version: 1 id: CHG-2026-056-8c75-audit-evidence-index-record-inclusion-v0 title: Audit Evidence Index + Record Inclusion v0 -status: proposed +status: implemented type: feature size: large verification_status: pending diff --git a/runecontext/changes/CHG-2026-057-d5c1-verification-plane-foundation-v0/status.yaml b/runecontext/changes/CHG-2026-057-d5c1-verification-plane-foundation-v0/status.yaml index f68f52bd..b7e17a71 100644 --- a/runecontext/changes/CHG-2026-057-d5c1-verification-plane-foundation-v0/status.yaml +++ b/runecontext/changes/CHG-2026-057-d5c1-verification-plane-foundation-v0/status.yaml @@ -1,7 +1,7 @@ schema_version: 1 id: CHG-2026-057-d5c1-verification-plane-foundation-v0 title: Verification Plane Foundation v0 -status: proposed +status: implemented type: project size: large verification_status: pending diff --git a/runecontext/changes/CHG-2026-058-04e9-verification-coverage-expansion-v0/status.yaml b/runecontext/changes/CHG-2026-058-04e9-verification-coverage-expansion-v0/status.yaml index cfa879a9..ffba6227 100644 --- a/runecontext/changes/CHG-2026-058-04e9-verification-coverage-expansion-v0/status.yaml +++ b/runecontext/changes/CHG-2026-058-04e9-verification-coverage-expansion-v0/status.yaml @@ -1,7 +1,7 @@ schema_version: 1 id: CHG-2026-058-04e9-verification-coverage-expansion-v0 title: Verification Coverage Expansion v0 -status: proposed +status: implemented type: feature size: large verification_status: pending diff --git a/runecontext/changes/CHG-2026-059-7b31-cross-machine-evidence-replication-restore-v0/design.md b/runecontext/changes/CHG-2026-059-7b31-cross-machine-evidence-replication-restore-v0/design.md index 58099008..00391225 100644 --- a/runecontext/changes/CHG-2026-059-7b31-cross-machine-evidence-replication-restore-v0/design.md +++ b/runecontext/changes/CHG-2026-059-7b31-cross-machine-evidence-replication-restore-v0/design.md @@ -209,6 +209,8 @@ This change should treat at least these as publication-sensitive: - pull request creation when it is the reviewed remote mutation act - other later remote-state mutation or publication actions classified into the same hard-floor lane +Local canonical RuneContext mutation from `CHG-2026-060-c1a4-beta-readiness-hardening-product-polish`, including draft promote/apply and local approved implementation, is not publication-sensitive by itself. It becomes publication-sensitive only when the resulting work is bound to a remote publication action such as push, tag, pull-request creation, or an equivalent future remote-state mutation. + ### Required Sequence Before a publication-sensitive action executes, RuneCode must: 1. seal or checkpoint the evidence that justifies the action diff --git a/runecontext/changes/CHG-2026-059-7b31-cross-machine-evidence-replication-restore-v0/proposal.md b/runecontext/changes/CHG-2026-059-7b31-cross-machine-evidence-replication-restore-v0/proposal.md index 2fc6574d..4b4e874b 100644 --- a/runecontext/changes/CHG-2026-059-7b31-cross-machine-evidence-replication-restore-v0/proposal.md +++ b/runecontext/changes/CHG-2026-059-7b31-cross-machine-evidence-replication-restore-v0/proposal.md @@ -28,6 +28,7 @@ Those shortcuts would conflict with the current verification-plane foundation, w - Add fetch-on-miss, restore, and anti-entropy repair flows driven by signed replication checkpoints and verified immutable object identities. - Freeze a durability barrier for publication-sensitive actions: required pre-action evidence must be sealed or checkpointed and durably replicated to the healthy replica set before the action executes. - Reuse durable prepared and execute plus reconcile semantics for publication-sensitive actions so crash recovery remains trustworthy even if a machine fails immediately after remote state mutation. +- Keep `CHG-2026-060-c1a4-beta-readiness-hardening-product-polish` local canonical RuneContext mutation and approved implementation out of the publication-sensitive class by default; those actions produce local evidence and workspace/RuneContext mutations, but they do not become publication-sensitive until a later remote publication action such as push, tag, pull-request creation, or equivalent remote-state mutation is requested. - Forbid a permanent lower-assurance publication path for degraded-state changes. If degraded-state work survives outside a healthy evidentiary run, RuneCode should capture it only as a recovery seed and re-create it through a fresh healthy audited run before publication. - Keep one topology-neutral architecture across constrained local devices and scaled deployments by varying only queue depth, cache size, and target count rather than logical trust semantics. - Keep downstream ownership boundaries explicit: this change owns replication checkpoints, remote S3-compatible durability targets, tenant and project namespace storage layout, thin-local GC eligibility and skeleton-state requirements, fetch-on-miss and anti-entropy repair, durability posture enforcement, publication durability barriers, and degraded-state recovery-seed plus healthy re-creation workflow. @@ -73,6 +74,7 @@ Freezing the replication, GC, and publication-durability model now avoids later - Allowing runner-owned, workflow-local, or client-local evidence federation authority. - Defining peer-to-peer replication as a required first implementation slice. - Allowing permanent lower-assurance publication of degraded-state changes. +- Treating local canonical RuneContext mutation or local approved implementation from CHG-060 as remote publication by itself. ## Impact This change creates one reviewed future path for multi-machine evidence durability: diff --git a/runecontext/changes/CHG-2026-059-7b31-cross-machine-evidence-replication-restore-v0/tasks.md b/runecontext/changes/CHG-2026-059-7b31-cross-machine-evidence-replication-restore-v0/tasks.md index 93fd365c..41a27cab 100644 --- a/runecontext/changes/CHG-2026-059-7b31-cross-machine-evidence-replication-restore-v0/tasks.md +++ b/runecontext/changes/CHG-2026-059-7b31-cross-machine-evidence-replication-restore-v0/tasks.md @@ -47,6 +47,7 @@ ## Publication-Sensitive Durability Barrier - [ ] Define the hard-floor publication-sensitive actions that must pass the durability barrier before execution. +- [ ] Keep CHG-060 local canonical RuneContext mutation and local approved implementation out of the publication-sensitive class unless and until a remote publication action is requested. - [ ] Require sealing or checkpointing, signed checkpoint creation, and successful replication of required evidence to the healthy replica set before publication execute. - [ ] Bind publication prepare records to exact repository identity, target refs, referenced patch or input digests, expected result tree hash, canonical action request hash, and evidence checkpoint digest. - [ ] Reuse durable prepared and execute plus reconcile semantics so crash recovery remains trustworthy if a machine fails immediately after remote state mutation. diff --git a/runecontext/changes/CHG-2026-059-7b31-cross-machine-evidence-replication-restore-v0/verification.md b/runecontext/changes/CHG-2026-059-7b31-cross-machine-evidence-replication-restore-v0/verification.md index 1a47ca70..e7c95c5b 100644 --- a/runecontext/changes/CHG-2026-059-7b31-cross-machine-evidence-replication-restore-v0/verification.md +++ b/runecontext/changes/CHG-2026-059-7b31-cross-machine-evidence-replication-restore-v0/verification.md @@ -14,6 +14,7 @@ - Confirm the change defines at least `healthy`, `remote_durability_degraded`, and `local_capture_unhealthy` durability posture with the intended action gates. - Confirm one remote target is explicitly degraded posture and healthy self-healing requires two independent remote targets. - Confirm publication-sensitive actions require a pre-action durability barrier and durable prepare, execute, and reconcile semantics rather than a best-effort flush. +- Confirm CHG-060 local canonical RuneContext mutation and local approved implementation are not treated as publication-sensitive by themselves. - Confirm degraded-state changes have no permanent lower-assurance publication lane and are only eligible for re-creation through a new healthy audited run. - Confirm fetch-on-miss, restore, and anti-entropy are checkpoint-driven and fail closed on ambiguous or unverifiable remote content. - Confirm any optional helper remains in the trusted domain and does not become a second public authority or restore-admission surface. diff --git a/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/design.md b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/design.md new file mode 100644 index 00000000..0d5ef264 --- /dev/null +++ b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/design.md @@ -0,0 +1,213 @@ +# Design + +## Overview +This change is an alpha hardening umbrella for turning RuneCode's existing foundations into a coherent, useful, beta-ready Linux-first product slice. + +It does not redefine the major architecture already in place. Instead, it sequences the remaining work needed to make the current architecture show up truthfully and usefully in normal operation. + +The central rule of this lane is: + +RuneCode should not claim beta readiness until the local canonical RuneContext lifecycle and productive workflow loop run through the real trusted and untrusted execution path, produce inspectable artifacts and audit evidence, and remain understandable to an operator using the normal product surfaces. + +This lane also coordinates directly with `CHG-2026-053-9d2b-performance-baselines-verification-gates-v0` for the surfaces that beta users actually experience. Dogfooding-driven polish must improve those surfaces without making them less authoritative or less measurable. + +## Scope +This lane covers six connected concerns: + +1. end-to-end workflow execution wiring +2. trusted `RunPlan` production adoption +3. canonical RuneContext project-substrate lifecycle proof +4. truthful runtime-attestation posture handoff to `CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0` +5. product and TUI polish discovered during dogfooding +6. release-surface and verification-smoke-path alignment + +## Execution Integration Goal + +### Current Shape +The current implementation already provides: + +- durable session and run state +- session execution trigger flows +- workflow-pack assets and workflow routing contracts +- trusted `RunPlan` compilation and persistence machinery +- runner kernel foundations and report schemas +- launcher and runtime-evidence foundations +- audit, artifact, and verification surfaces in the broker and TUI + +The main remaining gap is production wiring. + +### Required Product Shape +The required alpha.11 execution path is: + +1. operator starts or attaches to the repo-scoped product via `runecode` +2. operator inspects, adopts, initializes, or upgrades canonical RuneContext project substrate through broker-owned product surfaces when needed +3. operator triggers a supported first-party RuneContext workflow through the normal product path +4. trusted code validates project substrate and execution preconditions +5. trusted code selects and adopts the authoritative built-in workflow assets and compiles the exact immutable `RunPlan` +6. trusted code persists the authoritative plan and its execution bindings +7. the actual runner or isolate-backed execution path starts from that plan +8. runner checkpoints and results flow back through the broker's real typed surfaces +9. operator-visible session, run, artifact, approval, and audit surfaces all reflect that real path + +This lane is complete only when that path is real, not simulated by local-only state updates. + +## Trusted RunPlan Adoption +`CompileAndPersistRunPlan` already exists as a trusted foundation. This lane makes production workflow execution consume it as the real authority path rather than leaving it mostly validated by tests. + +The production path should make it obvious that: + +- the workflow assets are selected by trusted code +- the compiled plan is persisted before execution +- the runner or isolate consumes the authoritative plan identity +- later run-state, gate-state, and evidence links resolve back to that exact plan + +## Required Beta Workflow Slice +This lane requires the complete local canonical RuneContext workflow loop rather than a single draft-only demonstration. + +Required operations: + +- `change_draft` from prompt to typed change-draft artifact through the real execution path +- `spec_draft` from prompt to typed spec-draft artifact through the same real execution path +- `draft_promote_apply` for a reviewed change draft into canonical `runecontext/changes/` +- `draft_promote_apply` for a reviewed spec draft into canonical `runecontext/specs/` +- `approved_change_implementation` from one reviewed implementation input set containing one or more approved change/spec inputs by exact digest + +The proof chain should show that planning, canonical RuneContext mutation, and local implementation mutation all use the same broker-owned workflow authority model rather than separate product-local shortcuts. + +`approved_change_implementation` should be allowed to update required RuneContext lifecycle metadata when the approved input set calls for it. Examples include `tasks.md`, `status.yaml`, verification status, roadmap entries, and release-note surfaces. This lane should not add a separate fifth workflow operation for lifecycle close or metadata promotion unless later reviewed work needs it as an independently runnable command. + +The broader implementation-track decomposition and isolated-worktree execution roadmap remains follow-on unless a narrow part is required to make this approved implementation proof real. + +### Approved Implementation Input-Set Identity +The beta workflow slice must make the approved implementation input-set identity contract explicit before future implementation, collaboration, or git publication features depend on it. + +The contract has two digest domains: + +- `workflow_routing.bound_input_artifacts[].artifact_digest` identifies the exact stored canonical JSON artifact bytes supplied to the run. +- `implementation_input_set.input_set_digest` identifies the semantic input-set body: the canonical JSON object after omitting `input_set_digest` itself. + +Trusted broker validation must recompute the semantic digest from the stored payload and require it to match `implementation_input_set.input_set_digest`. Validation must also keep using the bound artifact digest for artifact retrieval and byte-level identity. A stored artifact can therefore be addressed by one digest while carrying a self-excluded semantic identity that is stable across storage wrappers and safe to use as the approved input-set identity. + +Run, audit, and projection code should avoid conflating the names. Where both are relevant, use `input_set_artifact_digest` for the routing-bound artifact identity and `input_set_digest` for the recomputed semantic input-set identity. + +## Project-Substrate Lifecycle Proof +Beta owns the canonical RuneContext lifecycle for a repository. This lane therefore requires a normal product proof for project substrate, not only a workflow proof. + +Required lifecycle coverage: + +- inspect and report current project-substrate posture +- adopt compatible existing substrate without silently rewriting it +- initialize missing substrate through explicit preview/apply +- upgrade compatible older substrate through explicit preview/apply +- re-run validation and status after apply +- keep normal productive workflow execution blocked when substrate posture is missing, invalid, non-verified, or unsupported + +These flows remain setup and remediation lifecycle, not built-in productive workflow operations. They still must be broker-owned, typed, auditable where apply occurs, and visible through TUI or CLI surfaces. + +## Runner Integration Goal +The runner kernel currently exposes transport seams that can still default to noop behavior outside explicit wiring. This lane should close that ambiguity for the real workflow path. + +Completion shape: + +- the actual workflow path uses a real broker transport for checkpoint and result reporting +- missing transport configuration is no longer the silent or default normal-operation story for a real run +- operator-visible run progress derives from real execution progress instead of only control-plane projection shortcuts + +## Attestation Truthfulness +This lane does not replace `CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0`, but it treats that change as a required companion for truthful beta assurance. + +The key integration rule is: + +- do not present supported `attested` posture as the settled beta story until `CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0` is implemented, verified, and integrated into the supported path + +This lane should therefore keep product and UX surfaces aligned with the true current posture while alpha hardening is in progress. + +## Git Remote Publication Boundary +Git remote mutation is not required for this beta close gate unless product messaging explicitly claims remote publication, prompt-to-PR, push, or team collaboration. + +The useful beta story can be local-first: + +- repo-scoped product lifecycle works +- project-substrate lifecycle works +- planning artifacts are generated +- canonical RuneContext files are mutated through audited promote/apply +- approved implementation mutates the local workspace through the real workflow path +- evidence, audit, and TUI surfaces make the work inspectable + +If beta messaging later expands to publishing or collaboration, add a narrow git remote `prepare -> exact approval -> execute` smoke path using the existing gateway contracts. That is intentionally not part of the required CHG-060 close shape. + +## Planning-Time Implementation Findings +The planning assessment found existing seams that this lane should close or explicitly replace: + +- `internal/brokerapi/local_api_session_execution_trigger_bridge.go` currently behaves as a synthetic bridge by recording a checkpoint and setting a run active rather than compiling, persisting, and launching from an authoritative plan. +- `internal/brokerapi/local_api_session_execution_trigger_binding.go` initializes run status and runtime facts for session execution, but that is not the same as real runner or isolate launch. +- `runner/src/broker-client.ts` still exposes noop runner broker-client behavior that must not be the normal supported workflow path. +- `runner/package.json` does not expose an obvious normal product runner launch entrypoint for the supported path. +- `internal/brokerapi/local_api_run_summary_ops.go` still has artifact-inferred workflow identity behavior that should become plan-authoritative for supported path projections. +- `cmd/runecode-tui/route_chat_state.go` should route supported beta operations intentionally rather than relying on an accidental or misleading default workflow operation. + +The positive foundation is also clear: trusted `RunPlan` compile/persist, active plan selection, built-in workflow catalog authority, broker runner report operations, project-substrate lifecycle APIs, and evidence/export/offline verification surfaces already exist and should be integrated rather than replaced. + +## Product Polish Goal +Dogfooding should be part of the plan, not an afterthought. + +This lane should capture polish work discovered while testing the real workflow path, especially in: + +- run and session state clarity +- attach, reconnect, and resume ergonomics +- project-substrate remediation guidance +- approval visibility and follow-up cues +- audit, artifact, and verification discoverability +- route naming, wording, and operator confidence signals +- waiting, blocked, degraded, and failed state communication + +The TUI is the highest-priority polish surface because it is the normal user-facing shell for the local product. + +TUI acceptance is intentionally dogfooding-gated rather than fully preplanned. Issues found during walkthroughs of the required paths should be captured, blockers and misleading product-truth issues should be fixed before closure, and non-blocking polish can be recorded as follow-up. + +That polish should stay aligned with the reviewed performance-contract discipline in `CHG-053`, especially: + +- attach, reconnect, and resume surfaces should continue to reflect broker-owned lifecycle truth rather than client-local optimistic shortcuts +- waiting, blocked, degraded, and failed states should remain operator-visible without reintroducing misleading high-activity rendering paths or synthetic progress cues +- dogfooding fixes should preserve the same authoritative surfaces that the MVP performance gates measure rather than optimizing around those gates with less truthful UI behavior + +## Verification Smoke Path +This lane should require that the real workflow path also exercises the verification surfaces already present in the repository. + +The alpha.11 smoke path should include: + +- run project-substrate inspect/adopt/init/upgrade coverage as applicable for deterministic fixtures +- run `change_draft` and `spec_draft` through the real workflow path +- promote/apply a reviewed change draft and a reviewed spec draft into canonical RuneContext files +- run `approved_change_implementation` from a reviewed implementation input set +- inspect resulting runs, artifacts, and audit records in the TUI or broker surfaces +- capture evidence snapshot +- inspect at least one record inclusion result +- export a bundle and verify it offline +- exercise external anchoring when appropriate and available + +The goal is not to finish every planned verification-plane feature here. The goal is to prove that beta ships with real evidence continuity from a real workflow path. + +## Release-Surface Alignment +This lane should end with roadmap, docs, and messaging that match the real state of the product. + +Specifically: + +- roadmap entries should reflect alpha.11 as the hardening lane and beta.1 as the milestone outcome +- the blank or incomplete alpha.11 roadmap feature-change wording should be resolved +- `CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0` should be represented consistently as a verified/integrated beta closure dependency when assurance wording depends on it +- product docs should describe the actual useful workflow story and current assurance posture honestly +- help text and operator-facing wording should not imply a stronger end-to-end or attestation story than the code provides + +## Exit Criteria +This alpha lane is complete when: + +- project-substrate lifecycle is proven through broker-owned product surfaces for inspect/adopt/init/upgrade/validate/status cases +- `change_draft`, `spec_draft`, `draft_promote_apply`, and `approved_change_implementation` run through the real trusted and untrusted path for the supported beta slice +- the path uses authoritative trusted `RunPlan` adoption in production +- run and session progress surfaces reflect real execution rather than only synthetic projection +- verification artifacts are generated and exercised from that real workflow path +- TUI and surrounding operator surfaces are polished enough that a new user can test the product coherently on Linux +- the beta story is honest about assurance and execution behavior +- workflow-path and TUI polish remain compatible with the authoritative surfaces and honest measurement boundaries frozen by `CHG-053` +- git remote publication is either explicitly out of beta messaging or covered by a separate reviewed smoke path before any publishing claim is made diff --git a/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/proposal.md b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/proposal.md new file mode 100644 index 00000000..772cd04e --- /dev/null +++ b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/proposal.md @@ -0,0 +1,82 @@ +## Summary +Close the remaining integration, truthfulness, and operator-experience gaps between RuneCode's currently implemented foundations and the first beta-ready product slice, while capturing the dogfooding and TUI polish needed to make that slice useful to real users on Linux. + +The beta-ready slice must prove the local canonical RuneContext lifecycle and productive workflow loop end to end: project-substrate lifecycle, change and spec drafting, draft promote/apply into canonical RuneContext files, approved implementation from reviewed inputs, and evidence-backed operator inspection through the normal product surfaces. + +## Problem +RuneCode now has most of the major foundations needed for a first beta story: verified RuneContext project lifecycle, direct-credential remote model access, local product lifecycle management, workflow-pack assets, signed runtime-asset admission, attestation evidence seams, external audit anchoring, and portable verification evidence surfaces. + +The remaining gaps are no longer primarily missing primitives. They are missing product integration and honest operator outcomes. + +Today the repo still shows a mismatch between what the product foundations imply and what a beta user can truthfully do: + +- session execution creates durable run and session state, but the real path to useful runner- or isolate-backed work is not yet wired end to end +- trusted `RunPlan` compilation exists, but production execution paths do not yet clearly adopt it as the authoritative entry to useful work +- the runner kernel still exposes noop/default transport seams rather than an obviously wired real broker-reporting path in normal operation +- project-substrate init, upgrade, and validation surfaces exist, but the beta needs to prove RuneCode owns that canonical RuneContext lifecycle through the normal product path +- first-party workflow assets exist for `change_draft`, `spec_draft`, `draft_promote_apply`, and `approved_change_implementation`, but beta cannot rely on asset existence alone; those operations need to run through the real trusted and untrusted path +- supported `attested` posture must remain truthful and depend on the post-handshake trusted verification posture from `CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0` +- the TUI and surrounding local-product UX need dogfooding-driven polish so the first beta is understandable, testable, and useful rather than merely impressive in architecture +- roadmap, docs, and product messaging need one explicit pre-beta hardening lane so beta remains a milestone outcome rather than a vague bucket for leftover integration work + +Without a dedicated alpha hardening lane, RuneCode risks declaring beta too early, with a product that is rich in control-plane machinery and verification surfaces but still one honest end-to-end workflow short of the usability bar. + +## Proposed Change +- Create one alpha.11 umbrella project lane that captures the remaining beta-readiness hardening and product polish work. +- Treat this lane as the integration and dogfooding bridge between implemented foundations and the `v0.1.0-beta.1` milestone outcome. +- Close the remaining end-to-end execution gap from session trigger to real trusted `RunPlan` adoption, runner or isolate launch, runner checkpoint and result reporting, and durable operator-visible state. +- Require the supported beta RuneContext workflow slice to be runnable and inspectable through the normal product path: `change_draft`, `spec_draft`, `draft_promote_apply`, and `approved_change_implementation`. +- Tighten the `approved_change_implementation` input-set identity contract before beta so the bound artifact digest and the semantic input-set digest are distinct, recomputed by trusted code, and fail closed on drift. +- Require canonical RuneContext project-substrate lifecycle proof through RuneCode-owned surfaces: inspect or adopt existing substrate, initialize missing substrate through preview/apply, upgrade supported older substrate through preview/apply, and validate/status the resulting posture. +- Track the production adoption of trusted `RunPlan` compilation rather than leaving it as a largely test-proven foundation seam. +- Track the replacement of effectively noop runner transport defaults with real broker integration in the actual workflow path. +- Fold in the truthful attestation-posture correction from `CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0` as a required pre-beta hardening companion. +- Keep workflow-path dogfooding, TUI polish, and operator-truth improvements aligned with the reviewed performance-contract discipline in `CHG-2026-053-9d2b-performance-baselines-verification-gates-v0`, especially around attach/resume truth, waiting-state communication, and avoidance of client-local shortcuts that would make measured product surfaces less honest. +- Explicitly capture TUI and operator polish discovered while dogfooding the real workflow path, especially around run state clarity, attach or reconnect behavior, remediation cues, approval and audit discoverability, and overall usability. +- Align roadmap and product-surface messaging with the real shipped state once the honest workflow path exists. +- Require the alpha lane to exercise verification artifacts on the real workflow path so beta ships with strong evidence continuity instead of a later degraded verification posture. +- Keep git remote publication out of the required beta close gate unless beta messaging explicitly claims prompt-to-PR, push, or team collaboration. Local canonical lifecycle and implementation are required; remote publication remains adjacent follow-on scope. + +## Why Now +The repository is no longer blocked mainly on basic platform capability. + +It is now at the point where the most important pre-beta work is to make the implemented pieces behave like one coherent product and to prove that the resulting path is useful in practice. + +Doing that as an explicit `v0.1.0-alpha.11` lane keeps the beta milestone clean: + +- alpha.11 becomes the hardening and dogfooding release +- beta.1 remains the first usable release outcome + +That split is easier to reason about than continuing to leave integration and polish work implicitly hidden under beta itself. + +## Assumptions +- The current foundations for project lifecycle, model access, workflow-pack assets, local broker lifecycle, signed runtime assets, attestation evidence, audit evidence export, and anchoring are strong enough that the main remaining risk is integration quality rather than missing architecture. +- RuneCode should ship beta only when the local canonical workflow loop runs through the honest trusted and untrusted execution path and is inspectable through the normal product surfaces. +- The required local canonical workflow loop includes project-substrate lifecycle, `change_draft`, `spec_draft`, `draft_promote_apply`, and `approved_change_implementation`. +- `approved_change_implementation` may include required RuneContext lifecycle metadata updates, such as `tasks.md`, `status.yaml`, verification status, roadmap, or release-note updates, when those updates are part of the approved implementation input set; a separate fifth workflow operation is not required for this lane. +- `approved_change_implementation` must preserve two digest domains: the routing-bound artifact digest identifies the exact stored payload bytes, while `input_set_digest` identifies the canonical input-set body with `input_set_digest` omitted. +- TUI and product polish discovered while dogfooding are legitimate alpha hardening work and should be planned explicitly rather than treated as incidental cleanup. +- Verification artifacts generated from the real workflow path must remain first-class deliverables of this lane so later verification work strengthens rather than backfills the beta story. +- Product polish in this lane must improve operator clarity without undermining the authoritative broker-owned and persisted surfaces that `CHG-053` measures and protects. + +## Out of Scope +- Replacing the broader beta milestone with a new version target. +- Replanning the full verification-plane foundation, performance-baseline program, or cross-machine replication roadmap. +- Treating polish work as a reason to expand the trust boundary or create new product-truth surfaces. +- Adding an independent fifth built-in workflow operation for lifecycle close or promotion metadata before beta. +- Requiring git remote push, pull-request creation, or team-collaboration publishing as a beta blocker unless beta messaging is expanded to claim remote publication. +- Completing the broader implementation-track decomposition and isolated-worktree execution roadmap from `CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0` beyond what is needed to prove the approved implementation workflow through the shared beta path. + +## Impact +If completed, this change gives RuneCode one explicit alpha lane to finish the work that matters most before beta: + +- canonical RuneContext project-substrate lifecycle proof +- change and spec drafting through the honest workflow path +- reviewed draft promote/apply into canonical RuneContext files +- approved implementation through the honest workflow path +- truthful runtime assurance posture +- dogfooded and more coherent TUI and operator surfaces +- release messaging that matches reality +- real workflow-generated verification artifacts that can anchor later trust improvements + +That should let `v0.1.0-beta.1` mean a usable product milestone instead of an architectural aspiration. diff --git a/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/standards.md b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/standards.md new file mode 100644 index 00000000..135613af --- /dev/null +++ b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/standards.md @@ -0,0 +1,21 @@ +## Applicable Standards +- `standards/product/roadmap-conventions.md` +- `standards/global/local-product-lifecycle-and-attach-contract.md` +- `standards/global/project-substrate-contract-and-lifecycle.md` +- `standards/global/session-execution-contract-and-watch-families.md` +- `standards/global/workflow-pack-routing-and-built-in-workflow-authority.md` +- `standards/global/protocol-schema-invariants.md` +- `standards/global/protocol-canonicalization-profile.md` +- `standards/product/tui-shell-input-and-command-surfaces.md` +- `standards/security/trusted-run-plan-authority-and-selection.md` +- `standards/security/trusted-runtime-evidence-and-broker-projection.md` +- `standards/security/audit-evidence-bundles-and-offline-verification.md` +- `standards/security/audit-evidence-index-and-record-inclusion.md` +- `standards/global/source-quality-enforcement-layering.md` + +## Resolution Notes +This alpha hardening umbrella is intentionally product-facing rather than architecture-replacing. + +The selected standards require RuneCode to keep broker-owned lifecycle and project-substrate truth authoritative, to preserve trusted `RunPlan` authority and built-in workflow selection, to keep the TUI as a strict client of broker-owned state, to preserve the reviewed runtime-evidence and attestation posture contracts, and to exercise the verification surfaces from canonical evidence rather than derived views alone. + +For this lane, that means the beta proof must cover both setup/remediation lifecycle and productive workflow execution: project-substrate inspect/adopt/init/upgrade/validate/status, `change_draft`, `spec_draft`, `draft_promote_apply`, and `approved_change_implementation`. The approved implementation proof must also preserve canonicalization discipline by separating exact stored artifact identity from the self-excluded semantic `input_set_digest` recomputed by trusted broker validation. Git remote publication remains governed by its existing gateway standards and is not part of the required beta close gate unless product messaging expands to claim publishing or collaboration. diff --git a/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/status.yaml b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/status.yaml new file mode 100644 index 00000000..d3dd75e9 --- /dev/null +++ b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/status.yaml @@ -0,0 +1,47 @@ +schema_version: 1 +id: CHG-2026-060-c1a4-beta-readiness-hardening-product-polish +title: Beta Readiness Hardening + Product Polish +status: implemented +type: project +size: large +verification_status: passed +context_bundles: + - product-planning + - go-control-plane +related_specs: [] +related_decisions: [] +related_changes: + - CHG-2026-053-9d2b-performance-baselines-verification-gates-v0 + - CHG-2026-045-7f4c-direct-credential-model-providers-v0 + - CHG-2026-046-a91d-runecontext-verified-project-substrate-compatibility-lifecycle-v0 + - CHG-2026-047-c3e2-local-control-plane-bootstrap-persistent-session-lifecycle-v0 + - CHG-2026-048-6b7a-session-execution-orchestration-v0 + - CHG-2026-049-1d4e-first-party-runecontext-workflow-pack-v0 + - CHG-2026-050-e3f8-workflow-definition-contract-binding-v0 + - CHG-2026-052-a7f1-tui-leader-sequences-command-mode-v0 + - CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0 +depends_on: + - CHG-2026-024-acde-deps-fetch-offline-cache + - CHG-2026-045-7f4c-direct-credential-model-providers-v0 + - CHG-2026-046-a91d-runecontext-verified-project-substrate-compatibility-lifecycle-v0 + - CHG-2026-047-c3e2-local-control-plane-bootstrap-persistent-session-lifecycle-v0 + - CHG-2026-048-6b7a-session-execution-orchestration-v0 + - CHG-2026-049-1d4e-first-party-runecontext-workflow-pack-v0 + - CHG-2026-050-e3f8-workflow-definition-contract-binding-v0 + - CHG-2026-053-9d2b-performance-baselines-verification-gates-v0 + - CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0 +informed_by: + - CHG-2026-002-33c5-git-gateway-commit-push-pr + - CHG-2026-024-acde-deps-fetch-offline-cache + - CHG-2026-025-5679-external-audit-anchoring-v0 + - CHG-2026-026-98be-image-toolchain-signing-pipeline + - CHG-2026-030-98b8-isolate-attestation-v0 + - CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0 + - CHG-2026-057-d5c1-verification-plane-foundation-v0 +supersedes: [] +superseded_by: [] +created_at: "2026-05-04" +closed_at: null +promotion_assessment: + status: pending + suggested_targets: [] diff --git a/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/tasks.md b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/tasks.md new file mode 100644 index 00000000..43883998 --- /dev/null +++ b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/tasks.md @@ -0,0 +1,84 @@ +# Tasks + +## Phase 1: Close The End-To-End Execution Gap + +- [x] Trace the normal product path from session execution trigger to useful work and remove any remaining synthetic-only bridges. +- [x] Wire trusted workflow selection and production adoption of authoritative built-in workflow assets for the supported first-party beta slice. +- [x] Wire trusted `RunPlan` compilation and persistence into the real execution path rather than leaving it as largely test-proven foundation. +- [x] Ensure the real execution path starts from the persisted authoritative plan identity. +- [x] Ensure real runner checkpoint and result reporting reaches the broker through the typed production path. +- [x] Remove ambiguity around noop/default runner transport behavior for the real supported workflow path. +- [x] Add or document the normal product runner launch entrypoint for the supported path. +- [x] Make run, session, and TUI workflow projections plan-authoritative for the supported path rather than artifact-inferred. + +## Phase 2: Prove Canonical RuneContext Project Lifecycle + +- [ ] Prove project-substrate inspect and posture reporting through normal product surfaces. +- [ ] Prove compatible existing substrate adoption remains read-only and does not silently rewrite discovered state. +- [ ] Prove missing substrate initialization through explicit preview/apply and follow-up validation/status. +- [ ] Prove supported older substrate upgrade through explicit preview/apply and follow-up validation/status. +- [ ] Prove normal productive workflow execution remains blocked for missing, invalid, non-verified, or unsupported substrate posture. +- [ ] Keep apply flows broker-owned, typed, auditable where mutation occurs, and visible through TUI or CLI surfaces. + +## Phase 3: Make The Required Workflow Loop Honestly Useful + +- [x] Deliver `change_draft` from prompt to typed change-draft artifact through the real product path. +- [x] Deliver `spec_draft` from prompt to typed spec-draft artifact through the same real product path. +- [x] Deliver `draft_promote_apply` for a reviewed change draft into canonical `runecontext/changes/`. +- [x] Deliver `draft_promote_apply` for a reviewed spec draft into canonical `runecontext/specs/`. +- [x] Deliver `approved_change_implementation` from one reviewed implementation input set containing one or more approved change/spec inputs by exact digest. +- [x] Enforce the approved implementation input-set identity contract: bound artifact digest for exact stored bytes, `input_set_digest` for the broker-recomputed canonical body with `input_set_digest` omitted, and fail-closed validation on drift. +- [x] Keep approved implementation run, audit, and projection fields from conflating `input_set_artifact_digest` and semantic `input_set_digest` where both identities matter. +- [x] Allow approved implementation to update required RuneContext lifecycle metadata when the approved input set requires it, without adding a separate lifecycle-close workflow operation in this lane. +- [x] Keep the supported workflow loop inspectable through runs, sessions, artifacts, approvals, and audit surfaces. +- [x] Ensure the supported workflow loop remains Linux-first and does not depend on future platform work. + +## Phase 4: Align Runtime Assurance Truthfulness + +- [ ] Coordinate the user-facing assurance story with `CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0`. +- [ ] Avoid presenting supported `attested` posture as settled beta truth until post-handshake gating is implemented, verified, and integrated into the supported path. +- [ ] Ensure product surfaces distinguish current runtime evidence state from the final intended beta attestation story. + +## Phase 5: TUI And Product Polish During Dogfooding + +- [ ] Capture TUI polish items discovered while testing the real workflow path. +- [ ] Improve clarity for waiting, blocked, degraded, failed, resumed, and completed states. +- [ ] Improve attach, reconnect, and resume ergonomics where dogfooding reveals rough edges. +- [ ] Improve project-substrate remediation and workflow follow-up guidance where operator confusion appears. +- [ ] Improve discoverability for artifacts, audit evidence, approvals, and verification actions. +- [ ] Tighten wording, route labels, and status cues so the product reads like one coherent local system. +- [ ] Fix blockers and misleading product-truth issues found during TUI walkthroughs before closure. +- [ ] Record non-blocking polish follow-ups when they do not block the supported beta proof. + +## Phase 6: Verification Smoke Path + +- [ ] Run the supported project-substrate lifecycle proof through normal product surfaces. +- [x] Run `change_draft`, `spec_draft`, `draft_promote_apply`, and `approved_change_implementation` through the real product path and confirm canonical evidence is generated. +- [x] Inspect the resulting run, artifacts, and audit records through normal product surfaces. +- [x] Exercise audit evidence snapshot on the real workflow path. +- [x] Exercise audit record inclusion on at least one real workflow-generated record. +- [x] Exercise evidence bundle export and offline verification on the real workflow path. +- [ ] Exercise external audit anchoring on the real workflow path where environment and policy allow. + +## Phase 7: Release-Surface Alignment + +- [x] Update roadmap and product-facing docs so alpha.11 is the hardening lane and beta.1 remains the milestone outcome. +- [x] Resolve incomplete alpha.11 roadmap wording, including any blank `Feature changes:` entry. +- [x] Represent `CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0` consistently where beta assurance wording depends on its verified integration. +- [ ] Align README, help text, and operator-facing wording with the real workflow and assurance story. +- [x] Ensure release messaging does not imply a stronger end-to-end or attestation posture than the code actually provides. +- [x] Keep git remote publication out of required beta messaging unless a separate reviewed publishing smoke path is added. + +## Acceptance Criteria + +- [ ] RuneCode proves canonical project-substrate lifecycle through inspect/adopt/init/upgrade/validate/status surfaces. +- [x] RuneCode runs `change_draft`, `spec_draft`, `draft_promote_apply`, and `approved_change_implementation` through the real trusted and untrusted execution path. +- [x] RuneCode promotes reviewed change and spec drafts into canonical RuneContext files through the shared audited mutation path. +- [x] RuneCode implements one reviewed implementation input set through the shared workflow system, including local workspace mutation and required RuneContext lifecycle metadata updates when approved. +- [x] Trusted `RunPlan` compilation and persistence are part of the real production workflow path. +- [x] Runner progress shown to operators comes from real reporting integration for the supported path. +- [x] The supported path is inspectable through session, run, artifact, approval, and audit surfaces. +- [x] Verification artifacts are generated and exercised from the same real workflow path. +- [ ] TUI and surrounding operator surfaces are polished enough that a new Linux user can test the product coherently. +- [x] Git remote publication is not implied unless explicitly verified by a separate publishing smoke path. +- [x] The beta story is more truthful and less scaffold-heavy after this alpha lane completes. diff --git a/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/verification.md b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/verification.md new file mode 100644 index 00000000..c45677d6 --- /dev/null +++ b/runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/verification.md @@ -0,0 +1,47 @@ +# Verification + +## Planned Checks +- `runectx validate --json` +- `runectx status --json` +- `go test ./...` +- `cd runner && npm run lint` +- `cd runner && npm test` +- `cd runner && npm run boundary-check` +- `just test` +- `just ci` + +## Implemented This Pass +- Fixed broker-side session execution tests around the real runner bridge by keeping production stdio subprocess launch intact while adding narrow in-process runner launch seams for deterministic tests. +- Verified the supported workflow slice now compiles and persists authoritative `RunPlan` state, launches/proxies the runner path, accepts runner checkpoint/result reports, and keeps run/session projections plan-authoritative. +- Added/updated smoke coverage that exercises `change_draft`, `spec_draft`, `draft_promote_apply`, and `approved_change_implementation`, then inspects runs, artifacts, approvals, audit records, evidence snapshot, inclusion, and offline bundle verification. +- External audit anchoring remains environment-conditional and is not claimed as a completed always-on smoke in this closure pass. +- TUI polish remains explicitly pending and is not marked complete here. + +## Required Product Smokes +- Project-substrate lifecycle smoke: inspect/posture, adopt compatible existing substrate, init preview/apply for missing substrate, upgrade preview/apply for supported older substrate, and validate/status after apply. +- Workflow smoke: run `change_draft` and `spec_draft` through the real trusted `RunPlan` and runner path. +- Promote/apply smoke: promote a reviewed change draft into `runecontext/changes/` and a reviewed spec draft into `runecontext/specs/` through the shared audited mutation path. +- Implementation smoke: run `approved_change_implementation` from one reviewed implementation input set and verify resulting local workspace mutation plus required RuneContext lifecycle metadata updates when included in the approved input. +- Approved implementation identity smoke: verify trusted code rejects an input set whose embedded `input_set_digest` does not equal the canonical semantic body digest recomputed with `input_set_digest` omitted, while still using the bound artifact digest for exact stored payload retrieval. +- Evidence smoke: inspect run/session/artifact/approval/audit surfaces, capture an evidence snapshot, verify at least one record-inclusion result, export an evidence bundle, and verify the bundle offline. +- External anchoring smoke: exercise external audit anchoring on the real workflow path where environment and policy allow. +- Git publication posture: confirm beta messaging does not claim push, pull request, prompt-to-PR, or team-collaboration publishing unless a separate reviewed git remote smoke path is added. + +## Verification Notes +- Confirm `runecontext/project/roadmap.md` places this change under `v0.1.0-alpha.11` and keeps `v0.1.0-beta.1` as the milestone framing. +- Confirm `CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0` is reflected consistently wherever beta assurance wording depends on its verified integration. +- Confirm the proposal treats this lane as integration and dogfooding hardening, not a replacement architecture. +- Confirm the design requires canonical project-substrate lifecycle proof through broker-owned product surfaces. +- Confirm the design requires `change_draft`, `spec_draft`, `draft_promote_apply`, and `approved_change_implementation` through the real trusted and untrusted execution path. +- Confirm the design requires production adoption of authoritative trusted `RunPlan` compilation and persistence. +- Confirm the design calls out runner transport and reporting integration rather than leaving noop/default runner transport ambiguous for the real path. +- Confirm run/session/TUI projections are plan-authoritative for the supported path rather than artifact-inferred. +- Confirm approved implementation can carry required RuneContext lifecycle metadata updates without creating a separate fifth workflow operation for this lane. +- Confirm approved implementation keeps `input_set_artifact_digest` and semantic `input_set_digest` distinct and fail-closed on embedded semantic digest drift. +- Confirm the tasks explicitly capture TUI and operator polish discovered while testing. +- Confirm the change requires exercising evidence snapshot, record inclusion, bundle export, and offline verification on the real workflow path. +- Confirm the design keeps product messaging and assurance wording aligned with actual implementation state. +- Confirm git remote publication remains out of required beta messaging unless a separate reviewed publishing smoke path is added. + +## Close Gate +Use the repository's standard verification flow before closing this change, with `just ci` as the parity gate after targeted workflow and product smokes pass. diff --git a/runecontext/changes/CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0/design.md b/runecontext/changes/CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0/design.md new file mode 100644 index 00000000..8ce94ce0 --- /dev/null +++ b/runecontext/changes/CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0/design.md @@ -0,0 +1,124 @@ +# Design + +## Overview +This change expands RuneCode's performance program beyond the MVP beta gate set defined in `CHG-2026-053-9d2b-performance-baselines-verification-gates-v0`. + +The design goal is to preserve the MVP gate set as a stable release contract while adding broader post-MVP coverage for: + +- broader performance coverage for and beyond the CHG-060 first-party workflow loop +- git-gateway publication paths and broader project-substrate fixture coverage +- larger broker and end-to-end fixture tiers +- tuned cross-platform gates beyond Linux-first numeric enforcement + +## Inherited Contract From CHG-053 + +This change extends the `CHG-053` performance foundation rather than redefining it. + +That means the post-MVP expansion should continue to use: + +- the reviewed performance-contract artifact family rather than a second baseline storage format +- the `CHG-053` metric taxonomy across exact, absolute-budget, regression-budget, and hybrid-budget checks unless explicitly refined by later reviewed work +- the reviewed `CHG-053` statistical defaults as the starting point for broader measurement classes +- the `CHG-053` timing-boundary rule that metrics terminate on reviewed broker-owned or persisted milestones whenever authoritative downstream surfaces exist +- the same topology-neutral architecture rule across constrained local devices and larger deployments + +## Layer Boundary + +### Layer 1: MVP Beta Gates +Owned by `CHG-053`, with CHG-060 defining the required product loop those gates and smokes must cover: + +- Linux-first numeric gates +- TUI idle and waiting behavior +- broker API and watch families for supported beta fixtures +- attach and resume +- supported workflow execution path for project-substrate lifecycle, `change_draft`, `spec_draft`, `draft_promote_apply`, and `approved_change_implementation` +- launcher startup and truthful attestation path +- model-gateway, dependency-fetch, audit, protocol, and external-anchor checks + +### Layer 2: Post-MVP Expansion +Owned by this change: + +- broader workflow-pack performance coverage, larger fixtures, and additional entrypoint timings beyond the CHG-060 beta smokes +- git-gateway publication and broader project-substrate performance suites +- larger broker-fixture ladders and heavier extended-Linux measurements +- tuned macOS and Windows numeric gates where feasible + +Layer 2 expands breadth and confidence. It does not introduce a second semantics model for thresholds, baselines, timing boundaries, or trust ownership. + +## Broader Workflow-Pack Coverage +The post-MVP workflow-pack expansion should broaden performance coverage for the CHG-060 beta workflow loop and add later workflow-pack surfaces that are useful but not release-defining for the first beta, such as: + +- larger draft artifact-generation fixture tiers beyond the beta smoke fixtures +- explicit draft promote/apply timing through the audited shared path across broader fixture sizes +- reviewed implementation-input-set validation or binding costs for approved-change implementation entry across larger and drift-sensitive fixtures +- direct CLI workflow-trigger latency for broader workflow families +- repo-scoped admission-control and idempotency timing across broader workflow-pack entry points +- fail-closed drift-triggered re-evaluation or recompilation costs across those broader surfaces + +These checks should remain deterministic and should continue to measure the same broker-owned immutable `RunPlan` architecture rather than an alternate fast path. + +Where broader workflow-pack checks add new timings, those timings should still terminate on reviewed broker-owned or persisted milestones rather than direct CLI-local proxies when authoritative downstream surfaces exist. + +## Git Gateway And Project-Substrate Coverage +This expansion lane should add explicit performance coverage for surfaces that are implemented and important, but not required in the first beta hard gate: + +- git remote prepare +- execute against local bare remotes +- project substrate posture and preview flows across broader fixture repos +- local project substrate apply flows beyond the CHG-060 lifecycle proof fixtures + +These checks should remain local-only and deterministic where possible. + +Where git-gateway and project-substrate paths add exact counts, latency budgets, or regression budgets, they should use the same metric taxonomy and reviewed statistical defaults inherited from `CHG-053`. + +## Larger Fixture Ladders And Heavier Extended Lanes +The MVP gate set intentionally avoids overloading the first release with the heaviest fixture program. This change should add: + +- larger broker-fixture ladders +- heavier workflow and ledger fixtures +- broader extended-Linux merge-queue or scheduled lanes +- wider drift and repair cost coverage where those surfaces are already supported + +The goal is to increase confidence at scale without turning the first beta PR lane into a noisy bottleneck. + +This expansion should treat larger fixture ladders as a broadening of the reviewed MVP fixture inventory, not as permission to abandon the deterministic fixture discipline established by `CHG-053`. + +## Cross-Platform Expansion +Linux remains the first authoritative numeric gate. This change is where cross-platform performance work becomes more ambitious. + +### macOS +As macOS virtualization and runtime support mature, add the same flow families where feasible and tune numeric thresholds for: + +- TUI startup and interaction +- broker local API and watch behavior +- supported workflow and launcher paths that are meaningful on macOS + +### Windows +As Windows runtime support matures, add the same flow families where feasible and tune numeric thresholds for: + +- TUI startup and interaction +- broker local API and watch behavior +- supported workflow and launcher paths that are meaningful on Windows + +Cross-platform expansion must preserve one topology-neutral architecture rather than implying platform-local authority shortcuts. + +It must also preserve one performance-contract model across platforms. Tuned thresholds and lane promotion may differ by environment, but artifact shape, metric semantics, timing-boundary discipline, and trust ownership should stay aligned. + +## CI Integration Shape +This change should favor: + +- extended Linux lanes for heavier measurements +- macOS and Windows smoke or trend lanes first +- gradual promotion of stable flow families into numeric-gated cross-platform lanes only after noise and baseline quality are understood + +Selected higher-noise metrics may also be promoted to tighter authoritative Linux measurement environments if shared Linux CI proves too noisy, but that promotion should be treated as lane refinement rather than as a new product architecture or new metric identity. + +Threshold storage and baseline governance should stay review-driven and check-only. + +## Design Risks To Avoid +- Do not let broader expansion erode the usefulness of the MVP gate set. +- Do not add flaky or externally networked checks. +- Do not treat cross-platform numeric tuning as a substitute for actual platform readiness. +- Do not introduce a second baseline artifact family or a second metric semantics model for post-MVP checks. +- Do not terminate broader timings at advisory client-local milestones when reviewed broker-owned or persisted milestones exist downstream. +- Do not reward trust-path bypasses just because they improve a benchmark number. diff --git a/runecontext/changes/CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0/proposal.md b/runecontext/changes/CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0/proposal.md new file mode 100644 index 00000000..49767bc6 --- /dev/null +++ b/runecontext/changes/CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0/proposal.md @@ -0,0 +1,60 @@ +## Summary +Expand RuneCode's performance program beyond the MVP beta gate set to cover broader performance coverage for and beyond the CHG-060 beta workflow loop, git-gateway publication paths, heavier fixture tiers, and tuned cross-platform verification gates once the Linux-first beta baseline is already in place. + +## Problem +`CHG-2026-053-9d2b-performance-baselines-verification-gates-v0` is now the MVP performance gate set for the first usable beta, and `CHG-2026-060-c1a4-beta-readiness-hardening-product-polish` defines the required beta product loop: project-substrate lifecycle, `change_draft`, `spec_draft`, `draft_promote_apply`, and `approved_change_implementation` through the real product path. That is the right first boundary, but it intentionally leaves valuable follow-on performance breadth outside the beta-critical lane: + +- larger workflow-pack fixture tiers and timing coverage beyond the deterministic CHG-060 beta smokes +- git-gateway performance checks because remote publication is not part of the CHG-060 beta close gate unless beta messaging expands to claim publishing or collaboration +- larger fixture ladders and heavier extended-lane measurements that improve scale confidence but are not release-defining for the first beta +- tuned macOS and Windows numeric gates after platform-specific runtime support and noise characteristics are better understood + +Without a separate post-MVP change, those deferred surfaces would either drift without an owner or keep getting pulled back into the MVP gate set in ways that slow beta without improving the truthfulness of the first release promise. + +## Proposed Change +- Create one post-MVP performance-expansion lane that extends the MVP gate foundation from `CHG-2026-053-9d2b-performance-baselines-verification-gates-v0`. +- Reuse the reviewed performance-contract artifact family introduced by `CHG-053` rather than creating a second baseline or threshold declaration format. +- Reuse the `CHG-053` metric taxonomy across exact, absolute-budget, regression-budget, and hybrid-budget checks unless a later reviewed follow-up deliberately refines that taxonomy. +- Reuse the `CHG-053` statistical defaults as the starting point for broader post-MVP checks, including repeated-sample robust comparison for microbenchmarks, median plus `p95` plus explicit ceilings for latency metrics, fixed-window average/median plus max guardrails for CPU/process-behavior metrics, and exact checks for deterministic invariant counts. +- Reuse the `CHG-053` timing-boundary rule so broader checks still terminate on reviewed broker-owned or persisted milestones rather than advisory client-local or launcher-local heuristics when authoritative downstream surfaces exist. +- Add explicit measurement that broadens the CHG-060 beta workflow loop, including larger draft artifact-generation fixtures, draft promote/apply timing, approved implementation input-set validation or binding costs, direct CLI workflow triggering, repo-scoped admission control or idempotency, and fail-closed drift-triggered re-evaluation or recompilation costs. +- Add explicit performance checks for git-gateway publication paths when those surfaces become part of the supported user workflow. +- Add broader project-substrate performance coverage beyond the CHG-060 lifecycle proof, including deterministic posture, preview, apply, and fixture-size expansion where useful. +- Expand from the reviewed MVP fixture inventory to larger broker-fixture ladders and heavier extended-Linux measurements that improve confidence beyond the first beta release-defining fixtures. +- Expand cross-platform performance verification from Linux-first smoke or trend collection toward tuned macOS and Windows numeric gates where feasible. +- Keep performance verification deterministic, CI-safe, and aligned with the same trust-boundary rules, broker-owned authority model, and topology-neutral architecture rule as correctness checks. +- Allow selected higher-noise metrics to be promoted to tighter authoritative Linux environments later if needed, but treat that as measurement-infrastructure refinement rather than a product-architecture fork. + +## Why Now +Splitting this work out now preserves a clean contract: + +- `CHG-053` owns the first MVP beta performance gates +- this change owns the broader post-MVP expansion + +That lets the first beta ship with serious performance discipline while still preserving an explicit lane for the larger program that should follow. + +## Assumptions +- The MVP gate set from `CHG-053` lands first and becomes the baseline for future expansion. +- Broader workflow-pack performance coverage, project-substrate fixture breadth, and git-gateway publication flows are important to measure, but they should not redefine the first beta gate set retroactively. +- The reviewed performance-contract artifact family, metric taxonomy, statistical defaults, and authoritative timing-boundary rules from `CHG-053` remain the starting contract for this expansion lane. +- Tuned macOS and Windows numeric gates should follow the relevant platform runtime and virtualization work rather than assuming Linux measurements transfer directly. +- Larger fixtures and heavier extended lanes are valuable for post-MVP confidence, but they should remain deterministic and CI-safe. + +## Out of Scope +- Replacing the MVP performance gate set in `CHG-053`. +- Weakening Linux-first required gates for the supported beta surface. +- Moving the CHG-060 required product smokes out of beta and into post-MVP performance work. +- Introducing non-deterministic benchmarks, live external dependency checks, or CI flows that mutate repo state. + +## Impact +This change keeps the broader performance program reviewable without making the first beta gate set too wide. + +If completed, RuneCode will gain a cleaner post-MVP path for: + +- broader performance coverage for and beyond the beta workflow loop +- git-gateway publication and broader project-substrate performance coverage +- larger fixture tiers and heavier extended lanes +- tuned macOS and Windows numeric gates beyond the Linux-first baseline +- broader coverage that still reuses the same reviewed artifact model, metric semantics, statistical defaults, and authoritative timing-boundary rules established in `CHG-053` + +That preserves the value of the MVP beta gates while keeping the larger performance program visible and intentional. diff --git a/runecontext/changes/CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0/standards.md b/runecontext/changes/CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0/standards.md new file mode 100644 index 00000000..b84dea93 --- /dev/null +++ b/runecontext/changes/CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0/standards.md @@ -0,0 +1,24 @@ +## Applicable Standards +- `standards/product/roadmap-conventions.md` +- `standards/global/control-plane-api-contract-shape.md` +- `standards/global/local-product-lifecycle-and-attach-contract.md` +- `standards/global/project-substrate-contract-and-lifecycle.md` +- `standards/global/session-execution-contract-and-watch-families.md` +- `standards/security/trust-boundary-interfaces.md` +- `standards/security/trust-boundary-layered-enforcement.md` +- `standards/security/runner-durable-state-and-replay.md` +- `standards/security/trusted-runtime-evidence-and-broker-projection.md` + +## Resolution Notes +This change exists to expand RuneCode's performance program after the MVP gate set is already in place. + +That includes freezing the following clarifications for post-MVP work: + +- broader workflow-pack performance coverage can gain explicit budgets without moving CHG-060 required product smokes out of the first beta lane +- git-gateway publication and broader project-substrate performance checks should remain deterministic and local-first where feasible +- larger fixture ladders and heavier extended lanes are valuable, but should not destabilize the MVP PR gate +- broader macOS and Windows numeric tuning should remain explicit follow-on work rather than implied parity with Linux before the platform lanes are ready +- post-MVP expansion should keep using the reviewed performance-contract artifacts, metric taxonomy, statistical defaults, and authoritative timing-boundary rules established by `CHG-053` unless a later reviewed change deliberately revises them +- threshold updates and baseline refreshes still require explicit review rather than silent CI mutation + +This change extends the MVP performance foundation from `CHG-053` rather than redefining RuneCode's trust or control-plane contracts. diff --git a/runecontext/changes/CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0/status.yaml b/runecontext/changes/CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0/status.yaml new file mode 100644 index 00000000..c8028a46 --- /dev/null +++ b/runecontext/changes/CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0/status.yaml @@ -0,0 +1,32 @@ +schema_version: 1 +id: CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0 +title: Performance Program Expansion + Cross-Platform Gates v0 +status: planned +type: feature +size: medium +verification_status: pending +context_bundles: + - product-planning + - ci-tooling +related_specs: [] +related_decisions: [] +related_changes: + - CHG-2026-053-9d2b-performance-baselines-verification-gates-v0 + - CHG-2026-002-33c5-git-gateway-commit-push-pr + - CHG-2026-049-1d4e-first-party-runecontext-workflow-pack-v0 + - CHG-2026-028-647e-windows-microvm-runtime-support + - CHG-2026-029-5e5e-macos-virtualization-polish +depends_on: + - CHG-2026-053-9d2b-performance-baselines-verification-gates-v0 + - CHG-2026-002-33c5-git-gateway-commit-push-pr + - CHG-2026-049-1d4e-first-party-runecontext-workflow-pack-v0 +informed_by: + - CHG-2026-028-647e-windows-microvm-runtime-support + - CHG-2026-029-5e5e-macos-virtualization-polish +supersedes: [] +superseded_by: [] +created_at: "2026-05-04" +closed_at: null +promotion_assessment: + status: pending + suggested_targets: [] diff --git a/runecontext/changes/CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0/tasks.md b/runecontext/changes/CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0/tasks.md new file mode 100644 index 00000000..1a501e1f --- /dev/null +++ b/runecontext/changes/CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0/tasks.md @@ -0,0 +1,56 @@ +# Tasks + +## Phase 1: Broader Workflow-Pack Performance Coverage + +- [ ] Reuse the reviewed performance-contract artifact family from `CHG-053` rather than defining a second baseline format for post-MVP checks. +- [ ] Reuse the `CHG-053` metric taxonomy and statistical defaults as the starting policy for broader checks unless later reviewed work explicitly refines them. +- [ ] Treat CHG-060 as the required beta product-smoke baseline for project-substrate lifecycle, `change_draft`, `spec_draft`, `draft_promote_apply`, and `approved_change_implementation`. +- [ ] Add deterministic performance checks that broaden the CHG-060 beta workflow loop beyond its release-defining smoke fixtures. +- [ ] Add larger deterministic draft artifact-generation checks where those surfaces are part of the supported post-MVP product story. +- [ ] Add broader deterministic draft promote/apply timing checks for canonical RuneContext mutation through the shared audited path. +- [ ] Add deterministic reviewed implementation-input-set validation or binding checks for approved-change implementation entry across larger and drift-sensitive fixtures. +- [ ] Add deterministic direct CLI workflow-trigger latency checks for broader workflow-pack entry points. +- [ ] Add deterministic repo-scoped admission-control and idempotency checks for broader workflow trigger paths. +- [ ] Add deterministic fail-closed re-evaluation or recompilation checks for project-context or approved-input drift on broader workflow-pack paths. +- [ ] Freeze authoritative timing boundaries for broader workflow-pack checks so they still terminate on reviewed broker-owned or persisted milestones. + +## Phase 2: Git Gateway And Project-Substrate Expansion + +- [ ] Add git gateway prepare performance checks against deterministic local fixture repos. +- [ ] Add git execute performance checks against deterministic local bare remotes. +- [ ] Add project-substrate posture and preview performance checks for deterministic fixture repos. +- [ ] Add local project-substrate apply performance checks for deterministic fixture repos beyond the CHG-060 lifecycle proof fixtures. +- [ ] Apply the inherited metric taxonomy and authoritative timing-boundary rules to git-gateway and project-substrate checks. + +## Phase 3: Larger Fixture Ladders And Heavier Lanes + +- [ ] Add larger broker unary API fixture tiers beyond the MVP-supported buckets. +- [ ] Add larger broker watch-family fixture tiers beyond the MVP-supported buckets. +- [ ] Add heavier workflow execution fixtures for extended Linux lanes. +- [ ] Add heavier audit-ledger and verification fixtures for extended Linux lanes. +- [ ] Keep heavier lanes deterministic and suitable for merge-queue or scheduled execution. +- [ ] Treat larger fixture ladders as expansion from the reviewed MVP fixture inventory rather than as a separate fixture model. + +## Phase 4: Cross-Platform Expansion + +- [ ] Run the same flow families where feasible on macOS and Windows as smoke or trend collection after the relevant platform runtime work matures. +- [ ] Tune stable macOS numeric thresholds where fixture noise and platform behavior are understood. +- [ ] Tune stable Windows numeric thresholds where fixture noise and platform behavior are understood. +- [ ] Preserve Linux as the first authoritative numeric gate until the broader cross-platform program is genuinely stable. +- [ ] Promote selected higher-noise metrics to tighter authoritative Linux environments only if shared Linux CI proves insufficient, without changing metric identity or product architecture. + +## Phase 5: Governance + +- [ ] Document the promotion path from smoke or trend collection to numeric-gated cross-platform checks. +- [ ] Document the review process for broadening performance coverage without weakening the MVP gate set. +- [ ] Keep threshold updates and baseline refreshes review-driven and check-only. +- [ ] Keep threshold storage, metric semantics, statistical defaults, and timing-boundary rules aligned with the inherited `CHG-053` performance contract unless explicitly revised by reviewed follow-up work. + +## Acceptance Criteria + +- [ ] RuneCode has explicit post-MVP performance checks that broaden the CHG-060 beta workflow loop without moving required beta product smokes out of CHG-060. +- [ ] Git-gateway and broader project-substrate paths each have at least one deterministic CI-compatible performance check. +- [ ] Larger fixture ladders and heavier extended-Linux lanes exist without destabilizing the MVP beta PR gate. +- [ ] macOS and Windows run the same flow families where feasible, with tuned numeric gates added only where stable and meaningful. +- [ ] The broader performance program reuses the `CHG-053` performance-contract artifacts, metric taxonomy, statistical defaults, and authoritative timing-boundary rules unless explicitly revised through later reviewed work. +- [ ] The broader performance program remains aligned with the same trust-boundary and broker-owned authority model as the MVP gate set. diff --git a/runecontext/changes/CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0/verification.md b/runecontext/changes/CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0/verification.md new file mode 100644 index 00000000..65ea96d0 --- /dev/null +++ b/runecontext/changes/CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0/verification.md @@ -0,0 +1,21 @@ +# Verification + +## Planned Checks +- `runectx validate --json` +- `runectx status --json` +- `just test` + +## Verification Notes +- Confirm the roadmap places this change under `v0.2 (Post-MVP)`. +- Confirm `CHG-053` remains the MVP beta performance gate set and this change is explicitly additive over it. +- Confirm `CHG-060` remains the required beta product-smoke owner for project-substrate lifecycle, `change_draft`, `spec_draft`, `draft_promote_apply`, and `approved_change_implementation`. +- Confirm the change explicitly reuses the `CHG-053` performance-contract artifact family rather than introducing a second baseline format. +- Confirm the change explicitly reuses the `CHG-053` metric taxonomy, statistical defaults, and authoritative timing-boundary rules as the starting post-MVP contract. +- Confirm the proposal captures broader performance coverage for and beyond the CHG-060 beta workflow loop, git-gateway publication paths, broader project-substrate fixture coverage, larger fixture ladders, and tuned cross-platform gates as the main deferred layer. +- Confirm the design keeps Linux as the first authoritative numeric gate while allowing broader macOS and Windows work to grow in a controlled way. +- Confirm larger fixture ladders are framed as an expansion of the reviewed MVP fixture inventory rather than a second fixture model. +- Confirm the tasks keep performance verification deterministic, CI-safe, and review-driven. +- Confirm the change does not weaken the MVP gate set by silently moving required beta checks out of `CHG-053` or CHG-060 product smokes out of CHG-060. + +## Close Gate +Use the repository's standard verification flow before closing this change. diff --git a/runecontext/project/roadmap.md b/runecontext/project/roadmap.md index 88da8650..53c66f90 100644 --- a/runecontext/project/roadmap.md +++ b/runecontext/project/roadmap.md @@ -5,26 +5,26 @@ Active lifecycle state lives in `runecontext/changes/*/status.yaml`, and durable ## Upcoming Features +### v0.1.0-alpha.11 + +- Beta Readiness Hardening + Product Polish + - RuneCode closes the remaining product-integration gaps before beta by proving the local canonical RuneContext lifecycle and supported workflow slice through the real trusted and untrusted execution path: project-substrate lifecycle, change/spec drafting, reviewed draft promote/apply, approved implementation, evidence continuity, and dogfooding-driven TUI polish. + - Project change: `runecontext/changes/CHG-2026-060-c1a4-beta-readiness-hardening-product-polish/` + - Feature changes: `CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0` remains part of the beta assurance closure story wherever supported `attested` posture is claimed. + ### v0.1.0-beta.1 - Usable End-to-End Linux-First Cut - - RuneCode reaches the first usable end-to-end release on Linux: verified RuneContext project lifecycle, remote model access via direct credentials, isolate-backed interactive and autonomous workflows, full TUI usage on the local machine, and the planned pre-beta assurance trio of signing, attestation, and external audit anchoring. -- Verification Plane Foundation v0 - - RuneCode defines one inspectable evidence-first verification foundation across canonical evidence, append-only sealing, runtime identity and attestation, portable evidence bundles, and explicit degraded-posture handling, delivered through scoped child features. - - Project change: `runecontext/changes/CHG-2026-057-d5c1-verification-plane-foundation-v0/` - - Feature changes: `runecontext/changes/CHG-2026-056-8c75-audit-evidence-index-record-inclusion-v0/`, `runecontext/changes/CHG-2026-055-546a-verification-evidence-preservation-bundle-export-v0/`, `runecontext/changes/CHG-2026-058-04e9-verification-coverage-expansion-v0/` -- Runtime Attestation Post-Handshake Gating v0 - - RuneCode only awards supported `attested` posture after a live runtime completes secure-session validation and post-handshake trusted runtime-proof verification, closing the remaining gap between the reviewed attestation design and launch-time implementation order. - - Planned change: `runecontext/changes/CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0/` -- Project Performance Baselines + Verification Gates v0 - - RuneCode establishes the deferred broader performance program after the alpha.7 TUI waiting-state repaint fix: deterministic CI gates for TUI idle and waiting behavior, broker APIs and watch families, runner and workflow execution, launcher startup, gateway overhead, audit and protocol verification, and end-to-end attach or resume flows. - - Planned change: `runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/` -- Cross-Machine Evidence Replication + Restore v0 - - RuneCode can replicate immutable canonical evidence and signed replication checkpoints across machines, restore missing evidence from remote durability targets, thin local historical storage safely, and block publication-sensitive actions until evidence durability is healthy. - - Planned change: `runecontext/changes/CHG-2026-059-7b31-cross-machine-evidence-replication-restore-v0/` + - RuneCode reaches the first usable local-first Linux beta slice: verified RuneContext project lifecycle, direct-credential remote model access, isolate-backed interactive and autonomous workflows for change/spec drafting, reviewed draft promote/apply, approved implementation, full TUI usage on the local machine, and an evidence-first assurance story that stays honest about supported `attested` posture and current verification/anchoring coverage. ### v0.2 (Post-MVP) +- Cross-Machine Evidence Replication + Restore v0 + - RuneCode can replicate immutable canonical evidence and signed replication checkpoints across machines, restore missing evidence from remote durability targets, thin local historical storage safely, and block publication-sensitive actions until evidence durability is healthy. + - Planned change: `runecontext/changes/CHG-2026-059-7b31-cross-machine-evidence-replication-restore-v0/` +- Performance Program Expansion + Cross-Platform Gates v0 + - RuneCode expands the MVP performance program to broader performance coverage for and beyond the beta workflow loop, git publication paths, larger fixture tiers, and tuned cross-platform verification gates beyond the Linux-first beta baseline. + - Planned change: `runecontext/changes/CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0/` - Approval Profiles (Strict/Permissive) - Add selectable human-in-the-loop profiles beyond MVP moderate. - Planned change: `runecontext/changes/CHG-2026-014-0c5d-approval-profiles-strict-permissive/` @@ -65,7 +65,7 @@ Active lifecycle state lives in `runecontext/changes/*/status.yaml`, and durable - Add explicit, auditable shared-workspace concurrency instead of relying on one-run-per-workspace indefinitely. - Planned change: `runecontext/changes/CHG-2026-027-71ed-workflow-concurrency-v0/` - Implementation Track Decomposition + Git Worktree Execution v0 - - RuneCode can decompose implementation work into low-coupling tracks, run eligible tracks in isolated git worktrees, pause only the dependent tracks for user input, and keep unrelated eligible work moving when it is safe to do so. + - RuneCode extends the beta approved-implementation baseline by decomposing implementation work into low-coupling tracks, running eligible tracks in isolated git worktrees, pausing only dependent tracks for user input, and keeping unrelated eligible work moving when safe. - Planned change: `runecontext/changes/CHG-2026-051-4b9d-implementation-track-decomposition-git-worktree-execution-v0/` ## Unscheduled (Needs Specs) @@ -184,4 +184,9 @@ Active lifecycle state lives in `runecontext/changes/*/status.yaml`, and durable - Project change: `runecontext/changes/CHG-2026-057-d5c1-verification-plane-foundation-v0/` - Feature changes: `runecontext/changes/CHG-2026-056-8c75-audit-evidence-index-record-inclusion-v0/`, `runecontext/changes/CHG-2026-055-546a-verification-evidence-preservation-bundle-export-v0/`, `runecontext/changes/CHG-2026-058-04e9-verification-coverage-expansion-v0/` - +- Runtime Attestation Post-Handshake Gating v0 + - RuneCode only awards supported `attested` posture after a live runtime completes secure-session validation and post-handshake trusted runtime-proof verification, closing the remaining gap between the reviewed attestation design and launch-time implementation order before beta assurance claims are treated as settled. + - Planned change: `runecontext/changes/CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0/` +- Project Performance Baselines + Verification Gates v0 + - RuneCode establishes the first MVP-grade performance baselines and deterministic Linux-first CI gates for the supported beta surface. The required shared-Linux lane enforces the current `required_shared_linux` subset, while launcher startup/attestation and external audit anchoring contracts are tracked as informational or `contract_pending_dependency` until their dependency paths are fully landed. + - Planned change: `runecontext/changes/CHG-2026-053-9d2b-performance-baselines-verification-gates-v0/` diff --git a/runecontext/project/standards-inventory.md b/runecontext/project/standards-inventory.md index f8971357..e476fa34 100644 --- a/runecontext/project/standards-inventory.md +++ b/runecontext/project/standards-inventory.md @@ -27,6 +27,7 @@ Recent notable standards for this branch: - `runecontext/standards/security/runner-durable-state-and-replay.md` - `runecontext/standards/security/runtime-image-signing-admission-and-verified-cache.md` - `runecontext/standards/security/trusted-runtime-evidence-and-broker-projection.md` +- `runecontext/standards/testing/performance-contract-governance.md` Recent additions should be reflected here when they become durable cross-cutting guidance rather than change-local design notes. diff --git a/runecontext/standards/ci/just-ci.md b/runecontext/standards/ci/just-ci.md index 9485d00f..c529e7ba 100644 --- a/runecontext/standards/ci/just-ci.md +++ b/runecontext/standards/ci/just-ci.md @@ -9,26 +9,33 @@ suggested_context_bundles: # `just ci` Convention -- `just ci` is the canonical local+CI parity command +- `just ci` is the canonical local check entrypoint +- CI may use `just ci-fast` plus dedicated required gates when a heavyweight check needs path-aware or merge-queue scheduling +- Required shared-Linux performance contracts run in the dedicated CI lane (`just ci-required-shared-linux`) rather than every local `just ci` run +- Install untrusted runner runtime dependencies before trusted Go tests when any `go test ./...` path can launch the product runner; do not assume `runner/node_modules` already exists on fresh checkouts or CI machines - `just ci` is check-only: - No formatters in write mode - No lockfile updates (`flake.lock`, `go.sum`, `package-lock.json`) - Put auto-fix behavior in separate recipes (example: `just fmt`) - Put explicit repair workflows that change tracked files in separate recipes or tools rather than inside `just ci` (example: `just refresh-release-vendor-hash`) -- Put formal model checking behind an explicit check-only recipe (currently `just model-check`) and include it in `just ci` when it is part of required parity +- Put formal model checking behind explicit check-only recipes (`just model-check-core`, `just model-check-replay`, `just model-check`) and include full model checking in `just ci` for local parity +- In GitHub CI, keep the formal security-kernel check as a dedicated required gate so PR pushes can run the core model for security-kernel-relevant code or protocol changes, run the full model for formal-spec/tooling/workflow changes, and run the full model on merge queue and `main` - Keep recipes cross-platform (Windows-friendly): avoid bash/unix-only tools and shell pipelines - Redundant explicit steps in `just ci` are allowed when they make failures clearer (example: runner lint even if tests also run lint) ```make ci: + just ci-fast + just model-check + +ci-fast: go run ./tools/gofmtcheck go run github.com/golangci/golangci-lint/cmd/golangci-lint@... go vet ./... go run ./tools/checksourcequality - just model-check + cd runner && npm ci go test ./... go build ./cmd/... - cd runner && npm ci cd runner && npm run lint cd runner && npm test cd runner && npm run boundary-check diff --git a/runecontext/standards/ci/nix-flake-ci-invariants.md b/runecontext/standards/ci/nix-flake-ci-invariants.md index fca53df0..e0eb1f9d 100644 --- a/runecontext/standards/ci/nix-flake-ci-invariants.md +++ b/runecontext/standards/ci/nix-flake-ci-invariants.md @@ -25,5 +25,5 @@ env: steps: - run: nix flake lock --no-update-lock-file - run: nix flake check --no-write-lock-file - - run: nix develop --no-write-lock-file -c just ci + - run: nix develop --no-write-lock-file -c just ci-fast ``` diff --git a/runecontext/standards/ci/windows-portability-matrix.md b/runecontext/standards/ci/windows-portability-matrix.md index ee6d4684..451a69a2 100644 --- a/runecontext/standards/ci/windows-portability-matrix.md +++ b/runecontext/standards/ci/windows-portability-matrix.md @@ -13,7 +13,7 @@ suggested_context_bundles: - Windows CI runs `just ci-portability` under PowerShell (no bash dependency) - Test Node "min + max" versions within `runner/package.json` `engines` (pin exact versions) - Pin Windows job tooling versions for reproducibility (Go, Node, just, gopls, baseline CLIs) -- Keep one canonical TLC/model-check gate in a single CI lane (currently Linux via `just ci`), and keep Windows focused on portability checks that do not depend on TLC runtime provisioning +- Keep the canonical TLC/model-check gate on Linux in a dedicated formal-security CI job, and keep Windows focused on portability checks that do not depend on TLC runtime provisioning - Keep failure-path tests portable: do not rely on POSIX-only chmod or permission semantics when a deterministic injected failure seam can exercise the same rollback or cleanup path on Windows ```yaml diff --git a/runecontext/standards/security/trusted-runtime-evidence-and-broker-projection.md b/runecontext/standards/security/trusted-runtime-evidence-and-broker-projection.md index 36d3822a..fa9ef766 100644 --- a/runecontext/standards/security/trusted-runtime-evidence-and-broker-projection.md +++ b/runecontext/standards/security/trusted-runtime-evidence-and-broker-projection.md @@ -19,9 +19,14 @@ When trusted runtime backends report launcher- or isolate-derived state into tru - When substantive persisted runtime evidence exists, prefer that evidence plus durable lifecycle state over placeholder defaults, runner-local status, or client inference when projecting operator-facing lifecycle and terminal outcomes - Keep operator-visible runtime posture split into separate axes such as `backend_kind`, `isolation_assurance_level`, `provisioning_posture`, and audit verification posture; do not collapse these into one overloaded status field - Keep runtime attestation support, verification, verifier-class, and related measurement-profile posture as broker-projected typed state derived from persisted evidence or authoritative contracts rather than as client-computed hints +- Treat signed runtime admission and verified-cache identity as necessary launch prerequisites, not as sufficient proof of supported `attested` posture on their own +- Launcher-generated receipt fields, synthetic binding material, or pre-persistence session summaries must not by themselves cause broker-authoritative projection to award supported `attested` posture +- Supported `attested` posture requires the reviewed trust order to hold end to end: validated secure session first, session-bound post-handshake runtime evidence second, trusted attestation verification third, and persisted evidence-backed broker projection last +- If launch-time facts or lifecycle inputs claim `attested` but persisted attestation evidence or verification is missing, unavailable, or invalid, authoritative projection must downgrade from that optimistic launch-time posture rather than preserving it - Keep instance-scoped backend posture read models explicit and broker-projected: expose the active runtime `instance_id`, selected and preferred backend kinds, reduced-assurance state, pending approval state, and linked policy/approval references through typed control-plane surfaces rather than client-local toggles or launcher-private side channels - Broker-owned runtime audit families should cover both pre-session launch outcomes and session lifecycle outcomes; `runtime_launch_admission` and `runtime_launch_denied` are first-class operator-facing evidence surfaces, not implied side effects of later session events - Broker-owned runtime audit events must reference persisted evidence digests and canonical runtime identity fields rather than launcher-private host paths, hypervisor argv, transport allocation details, or scraped stderr text +- When runtime audit payloads reference attestation state or evidence, derive that linkage from persisted post-handshake attestation evidence and verification records rather than from optimistic launch-time placeholders - Runtime audit `operation_id` and audit-emission dedupe identity must be derived from the evidence that defines the event. Launch events should stay launch-evidence scoped, while session lifecycle events should include the launch, hardening, and session evidence needed to avoid collisions or drift across retries and later projections - When broker persists runtime-provenance receipts or summaries such as runtime summary, degraded posture summary, or negative-capability summary, derive them from canonical persisted receipts and approval or boundary evidence rather than from client-local interpretation or transient UI state - Treat runtime-provenance summaries as signed review evidence about what did or did not happen at the trusted boundary; absence claims such as no secret lease, no network egress, or no approval consumption must be backed by explicit evidence-support posture instead of implied silence diff --git a/runecontext/standards/testing/performance-contract-governance.md b/runecontext/standards/testing/performance-contract-governance.md new file mode 100644 index 00000000..793387f7 --- /dev/null +++ b/runecontext/standards/testing/performance-contract-governance.md @@ -0,0 +1,27 @@ +--- +schema_version: 1 +id: testing/performance-contract-governance +title: Performance Contract Governance +status: active +suggested_context_bundles: + - ci-tooling +--- + +# Performance Contract Governance + +Use `tools/perfcontracts/manifest.json` as the authoritative inventory for checked-in performance contracts and reviewed baselines. + +- Keep performance contracts separate from `runecontext/assurance/baseline.yaml` +- Keep CI check-only: verification must never auto-rewrite performance baselines +- Require explicit `threshold_origin` per threshold: `product_budget | investigation_baseline | first_calibration | temporary_guardrail` +- Require explicit timing boundaries (`start_event`, `end_event`, `clock_source`, `evidence_source`, `included_phases`) for every metric +- Treat the manifest baseline entry for each metric as authoritative: required `regression-budget` and `hybrid-budget` metrics must point `baseline_ref` at the exact path registered in `tools/perfcontracts/manifest.json` +- Reject duplicate `metric_id` entries in manifest baselines; provenance must never depend on last-write-wins manifest ordering +- Treat required enforcement as the intersection of reviewed `lane_authority` and `activation_state: required`; defined, informational, and `contract_pending_dependency` metrics stay outside required numeric enforcement +- Keep the shared-Linux required lane truthful: it enforces only the current checked-in `required_shared_linux` subset, while broader surfaces may remain informational or `contract_pending_dependency` +- Keep perf-tool diagnostics sanitized: do not leak sensitive local paths, tokens, or raw startup output in check failures +- Keep measurement boundaries honest: validate fixture or path preconditions before timing, measure fresh-process startup or attach when startup cost is in scope, and preserve the authoritative timing source when a script or tool emits the measurement directly +- For broker mutation metrics, seed approvals, blocked turns, policy context, and other preconditions outside the timed region unless the contract boundary explicitly includes that setup work +- When a contract says a mutation ends at ack or persistence, do not let the harness silently include runner launch, bridge execution, run-sync, checkpoint publication, or unrelated post-resolution side effects unless the checked-in timing boundary explicitly names those phases +- Treat baseline refresh as explicit reviewed change; do not hide threshold loosening in silent baseline updates +- Keep broader fixture ladders and cross-platform expansion in `CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0` diff --git a/runner/package.json b/runner/package.json index f1a60533..754641c0 100644 --- a/runner/package.json +++ b/runner/package.json @@ -7,8 +7,9 @@ "node": ">=22.22.1 <25" }, "scripts": { + "start": "node --experimental-strip-types src/cli.ts", "lint": "tsc --noEmit", - "test": "npm run lint && node --experimental-strip-types --test scripts/boundary-check.test.js scripts/protocol-fixtures.test.js scripts/runner-durable-state.test.js scripts/runner-kernel-runtime.test.js scripts/runner-kernel-foundation.test.js", + "test": "npm run lint && node --experimental-strip-types --test scripts/boundary-check.test.js scripts/protocol-fixtures.test.js scripts/runner-durable-state.test.js scripts/runner-kernel-runtime.test.js scripts/runner-kernel-runtime-execution.test.js scripts/runner-kernel-runtime-dependency-handoff.test.js scripts/runner-cli-product-path.test.js scripts/runner-kernel-foundation.test.js", "boundary-check": "node scripts/boundary-check.js" }, "devDependencies": { diff --git a/runner/scripts/perf-runner-workflow.js b/runner/scripts/perf-runner-workflow.js new file mode 100644 index 00000000..f56ce23a --- /dev/null +++ b/runner/scripts/perf-runner-workflow.js @@ -0,0 +1,139 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { performance } = require("node:perf_hooks"); + +const repoRoot = path.resolve(__dirname, "..", ".."); + +async function loadRunner() { + return import("../src/index.ts"); +} + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (!token.startsWith("--")) { + continue; + } + const key = token.slice(2); + const value = argv[i + 1]; + if (value === undefined || value.startsWith("--")) { + out[key] = ""; + continue; + } + out[key] = value; + i += 1; + } + return out; +} + +async function loadPlan(runplanPath) { + const { ProtocolSchemaBundle, RunPlanLoader } = await loadRunner(); + const schemaBundle = await ProtocolSchemaBundle.fromProtocolSchemasRoot(path.join(repoRoot, "protocol", "schemas")); + const loader = new RunPlanLoader(schemaBundle); + const resolvedRunplanPath = fs.realpathSync(path.resolve(runplanPath)); + const tmpRoot = path.resolve(os.tmpdir()); + if (!resolvedRunplanPath.startsWith(`${tmpRoot}${path.sep}`) && !resolvedRunplanPath.startsWith(`${repoRoot}${path.sep}`)) { + throw new Error("--runplan must resolve under the repository root or system temp directory"); + } + const raw = fs.readFileSync(resolvedRunplanPath, "utf8"); + const parsed = JSON.parse(raw); + return loader.loadFromUnknown(parsed); +} + +async function runMode(mode, runplanPath, fixtureID) { + const { PlanScheduler } = await loadRunner(); + const plan = await loadPlan(runplanPath); + const scheduler = new PlanScheduler(); + const normalizedFixtureID = String(fixtureID || "").trim(); + + const expectFirstPartyMinimalFixture = () => { + if (normalizedFixtureID !== "workflow.first-party-minimal.v1") { + throw new Error(`mode ${mode} requires --fixture workflow.first-party-minimal.v1`); + } + if (String(plan.workflow_id || "").trim() !== "workflow_first_party_minimal") { + throw new Error(`mode ${mode} requires workflow_id workflow_first_party_minimal`); + } + if (String(plan.process_id || "").trim() !== "process_first_party_minimal") { + throw new Error(`mode ${mode} requires process_id process_first_party_minimal`); + } + }; + + switch (mode) { + case "cold-start": { + const start = performance.now(); + const work = scheduler.listPlannedWork(plan); + if (!Array.isArray(work) || work.length === 0) { + throw new Error("cold-start failed: no planned work"); + } + return Math.max(0, Math.round(performance.now() - start)); + } + case "workflow-path": { + expectFirstPartyMinimalFixture(); + const start = performance.now(); + const blocked = scheduler.listPlannedWork(plan, { + pending_approval_waits: [{ blocked_scope: { scope_kind: "run", run_id: plan.run_id } }], + }); + if (!Array.isArray(blocked) || blocked.length !== 0) { + throw new Error("workflow-path failed: expected wait-scoped blocking"); + } + const first = scheduler.listPlannedWork(plan, { pending_approval_waits: [], completed_entry_ids: [] }); + if (!Array.isArray(first) || first.length === 0) { + throw new Error("workflow-path failed: no schedulable work on supported path"); + } + const completed = new Set(first.map((w) => w.entry.entry_id)); + const second = scheduler.listPlannedWork(plan, { pending_approval_waits: [], completed_entry_ids: [...completed] }); + if (!Array.isArray(second)) { + throw new Error("workflow-path failed: invalid scheduler result"); + } + return Math.max(0, Math.round(performance.now() - start)); + } + case "first-party-beta": { + expectFirstPartyMinimalFixture(); + const start = performance.now(); + const work = scheduler.listPlannedWork(plan, { pending_approval_waits: [] }); + if (work.length < 1) { + throw new Error("first-party-beta failed: no schedulable entry"); + } + if (work[0]?.entry?.entry_id !== "quality_lint" || work[0]?.entry?.entry_kind !== "gate") { + throw new Error("first-party-beta failed: fixture does not match supported first-party beta slice"); + } + return Math.max(0, Math.round(performance.now() - start)); + } + case "immutable-startup": { + const start = performance.now(); + const serialized = JSON.stringify(plan); + const roundtrip = JSON.parse(serialized); + if (roundtrip.plan_id !== plan.plan_id) { + throw new Error("immutable-startup failed: plan roundtrip mismatch"); + } + const work = scheduler.listPlannedWork(roundtrip, { pending_approval_waits: [] }); + if (work.length === 0) { + throw new Error("immutable-startup failed: no planned work"); + } + return Math.max(0, Math.round(performance.now() - start)); + } + default: + throw new Error(`unsupported --mode ${mode}`); + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const mode = String(args.mode || "").trim(); + const runplanPath = String(args.runplan || "").trim(); + const fixtureID = String(args.fixture || "").trim(); + if (!mode || !runplanPath) { + throw new Error("--mode and --runplan are required"); + } + const wallMs = await runMode(mode, runplanPath, fixtureID); + process.stdout.write(`${wallMs}\n`); +} + +main().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exit(1); +}); diff --git a/runner/scripts/perf-runner-workflow.test.js b/runner/scripts/perf-runner-workflow.test.js new file mode 100644 index 00000000..3479ed2f --- /dev/null +++ b/runner/scripts/perf-runner-workflow.test.js @@ -0,0 +1,196 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { spawnSync } = require("node:child_process"); + +const scriptPath = path.join(__dirname, "perf-runner-workflow.js"); + +function writeRunPlan(root, overrides = {}) { + const runPlan = { + schema_id: "runecode.protocol.v0.RunPlan", + schema_version: "0.4.0", + plan_id: "plan_workflow_first_party_minimal", + run_id: "run_workflow_first_party_minimal", + workflow_id: "workflow_first_party_minimal", + workflow_version: "1.0.0", + process_id: "process_first_party_minimal", + approval_profile: "moderate", + autonomy_posture: "balanced", + workflow_definition_hash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + process_definition_hash: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + policy_context_hash: "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + compiled_at: "2026-01-01T00:00:00Z", + role_instance_ids: ["role_alpha"], + executor_bindings: [{ + binding_id: "binding_alpha", + executor_id: "executor_alpha", + executor_class: "workspace_ordinary", + allowed_role_kinds: ["developer"], + }], + gate_definitions: [{ + schema_id: "runecode.protocol.v0.GateDefinition", + schema_version: "0.2.0", + gate: { + schema_id: "runecode.protocol.v0.GateContract", + schema_version: "0.1.0", + gate_id: "lint", + gate_kind: "lint", + gate_version: "0.1.0", + normalized_inputs: [], + plan_binding: { checkpoint_code: "quality", order_index: 0 }, + retry_semantics: { retry_mode: "new_attempt_required", max_attempts: 2 }, + override_semantics: { override_mode: "policy_action_required", action_kind: "action_gate_override", approval_trigger_code: "gate_override" }, + }, + checkpoint_code: "quality", + order_index: 0, + stage_id: "quality_stage", + step_id: "quality_lint", + role_instance_id: "role_alpha", + executor_binding_id: "binding_alpha", + dependency_cache_handoffs: [{ + request_digest: { hash_alg: "sha256", hash: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" }, + consumer_role: "workspace", + required: true, + }], + }], + dependency_edges: [], + entries: [{ + entry_id: "quality_lint", + entry_kind: "gate", + order_index: 0, + stage_id: "quality_stage", + step_id: "quality_lint", + role_instance_id: "role_alpha", + executor_binding_id: "binding_alpha", + checkpoint_code: "quality", + gate: { + schema_id: "runecode.protocol.v0.GateContract", + schema_version: "0.1.0", + gate_id: "lint", + gate_kind: "lint", + gate_version: "0.1.0", + normalized_inputs: [], + plan_binding: { checkpoint_code: "quality", order_index: 0 }, + retry_semantics: { retry_mode: "new_attempt_required", max_attempts: 2 }, + override_semantics: { override_mode: "policy_action_required", action_kind: "action_gate_override", approval_trigger_code: "gate_override" }, + }, + dependency_cache_handoffs: [{ + request_digest: { hash_alg: "sha256", hash: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" }, + consumer_role: "workspace", + required: true, + }], + depends_on_entry_ids: [], + blocks_entry_ids: [], + supported_wait_kinds: ["waiting_operator_input", "waiting_approval"], + }], + ...overrides, + }; + const runplanPath = path.join(root, "runplan.json"); + fs.writeFileSync(runplanPath, JSON.stringify(runPlan, null, 2)); + return runplanPath; +} + +function runPerf(mode, runplanPath, fixture) { + const args = ["--experimental-strip-types", scriptPath, "--mode", mode, "--runplan", runplanPath]; + if (fixture) { + args.push("--fixture", fixture); + } + return spawnSync(process.execPath, args, { encoding: "utf8" }); +} + +test("workflow-path requires supported first-party fixture argument", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-perf-workflow-")); + try { + const runplanPath = writeRunPlan(root); + const result = runPerf("workflow-path", runplanPath, ""); + assert.equal(result.status, 1); + assert.match(result.stderr, /requires --fixture workflow\.first-party-minimal\.v1/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("workflow-path rejects supported fixture when no work is schedulable", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-perf-workflow-")); + try { + const runplanPath = writeRunPlan(root, { + dependency_edges: [{ + dependency_kind: "step_completed", + upstream_step_id: "quality_lint", + downstream_step_id: "quality_lint", + }], + entries: [{ + entry_id: "quality_lint", + entry_kind: "gate", + order_index: 0, + stage_id: "quality_stage", + step_id: "quality_lint", + role_instance_id: "role_alpha", + executor_binding_id: "binding_alpha", + checkpoint_code: "quality", + gate: { + schema_id: "runecode.protocol.v0.GateContract", + schema_version: "0.1.0", + gate_id: "lint", + gate_kind: "lint", + gate_version: "0.1.0", + normalized_inputs: [], + plan_binding: { checkpoint_code: "quality", order_index: 0 }, + retry_semantics: { retry_mode: "new_attempt_required", max_attempts: 2 }, + override_semantics: { override_mode: "policy_action_required", action_kind: "action_gate_override", approval_trigger_code: "gate_override" }, + }, + dependency_cache_handoffs: [{ + request_digest: { hash_alg: "sha256", hash: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" }, + consumer_role: "workspace", + required: true, + }], + depends_on_entry_ids: ["quality_lint"], + blocks_entry_ids: ["quality_lint"], + supported_wait_kinds: ["waiting_operator_input", "waiting_approval"], + }], + }); + const result = runPerf("workflow-path", runplanPath, "workflow.first-party-minimal.v1"); + assert.equal(result.status, 1); + assert.match(result.stderr, /no schedulable work on supported path/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("workflow-path accepts supported fixture and runplan", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-perf-workflow-")); + try { + const runplanPath = writeRunPlan(root); + const result = runPerf("workflow-path", runplanPath, "workflow.first-party-minimal.v1"); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout.trim(), /^\d+$/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("first-party-beta rejects non-supported runplan identity", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-perf-workflow-")); + try { + const runplanPath = writeRunPlan(root, { workflow_id: "workflow_other" }); + const result = runPerf("first-party-beta", runplanPath, "workflow.first-party-minimal.v1"); + assert.equal(result.status, 1); + assert.match(result.stderr, /requires workflow_id workflow_first_party_minimal/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("first-party-beta accepts supported fixture and runplan", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-perf-workflow-")); + try { + const runplanPath = writeRunPlan(root); + const result = runPerf("first-party-beta", runplanPath, "workflow.first-party-minimal.v1"); + assert.equal(result.status, 0); + assert.match(result.stdout.trim(), /^\d+$/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/runner/scripts/runner-cli-product-path.test.js b/runner/scripts/runner-cli-product-path.test.js new file mode 100644 index 00000000..4dc0f3f5 --- /dev/null +++ b/runner/scripts/runner-cli-product-path.test.js @@ -0,0 +1,385 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { spawnSync } = require("node:child_process"); +const { createHash } = require("node:crypto"); + +const { repoRoot, validRunPlanFixture } = require("./runner-test-helpers.js"); + +const cliPath = path.join(repoRoot, "runner", "src", "cli.ts"); + +function writePlan(root) { + const planPath = path.join(root, "runplan.json"); + fs.writeFileSync(planPath, JSON.stringify(validRunPlanFixture(), null, 2)); + return planPath; +} + +function dependencyHandoffRequestID(runID, requestDigest) { + return `dependency-handoff:${createHash("sha256").update(runID).update("\n").update(requestDigest).digest("hex")}`; +} + +function runCLI(args, options = {}) { + return spawnSync( + process.execPath, + ["--experimental-strip-types", cliPath, ...args], + { + cwd: options.cwd ?? path.join(repoRoot, "runner"), + encoding: "utf8", + input: options.input, + env: { + ...process.env, + RUNECODE_PROTOCOL_SCHEMAS_ROOT: path.join(repoRoot, "protocol", "schemas"), + ...(options.env ?? {}), + }, + }, + ); +} + +test("cli fails closed when broker transport is missing", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-cli-")); + try { + const planPath = writePlan(root); + const result = runCLI(["--plan-file", planPath, "--plan-root", root]); + assert.equal(result.status, 1); + assert.match(result.stderr, /runner broker transport is required/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("cli rejects plan files outside the declared plan root", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-cli-")); + const otherRoot = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-cli-other-")); + try { + const planPath = writePlan(otherRoot); + const result = runCLI(["--plan-file", planPath, "--plan-root", root, "--broker-transport", "stdio"]); + assert.equal(result.status, 1); + assert.match(result.stderr, /--plan-file must resolve inside --plan-root/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(otherRoot, { recursive: true, force: true }); + } +}); + +test("cli rejects plan files that escape plan root through symlinks", (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-cli-")); + const otherRoot = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-cli-other-")); + try { + const escapedPlanPath = writePlan(otherRoot); + const linkedDir = path.join(root, "linked"); + try { + fs.symlinkSync(otherRoot, linkedDir, "dir"); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? error.code : ""; + if (["EPERM", "EACCES", "ENOTSUP"].includes(code)) { + t.skip(`symlink creation unavailable: ${code}`); + } + throw error; + } + const symlinkedPlanPath = path.join(linkedDir, path.basename(escapedPlanPath)); + const result = runCLI(["--plan-file", symlinkedPlanPath, "--plan-root", root, "--broker-transport", "stdio"]); + assert.equal(result.status, 1); + assert.match(result.stderr, /--plan-file must resolve inside --plan-root/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(otherRoot, { recursive: true, force: true }); + } +}); + +test("cli rejects caller-supplied protocol schema roots", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-cli-")); + try { + const planPath = writePlan(root); + const result = runCLI(["--plan-file", planPath, "--plan-root", root, "--protocol-schemas-root", root]); + assert.equal(result.status, 1); + assert.match(result.stderr, /--protocol-schemas-root is not supported/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("cli rejects unknown flags", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-cli-")); + try { + const planPath = writePlan(root); + const result = runCLI(["--plan-file", planPath, "--plan-root", root, "--unknown-flag"]); + assert.equal(result.status, 1); + assert.match(result.stderr, /unknown argument: --unknown-flag/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("cli rejects missing required flag values", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-cli-")); + try { + const result = runCLI(["--plan-file", "--plan-root", root]); + assert.equal(result.status, 1); + assert.match(result.stderr, /--plan-file requires a value/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("cli fails closed when protocol schema root env is missing", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-cli-")); + try { + const planPath = writePlan(root); + const result = runCLI(["--plan-file", planPath, "--plan-root", root], { env: { RUNECODE_PROTOCOL_SCHEMAS_ROOT: "" } }); + assert.equal(result.status, 1); + assert.match(result.stderr, /RUNECODE_PROTOCOL_SCHEMAS_ROOT is required/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("cli fails closed when protocol schema root env lacks required runner schemas", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-cli-")); + const fakeSchemas = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-schemas-")); + try { + const planPath = writePlan(root); + fs.writeFileSync(path.join(fakeSchemas, "manifest.json"), JSON.stringify({ schema_files: [] }, null, 2)); + const result = runCLI(["--plan-file", planPath, "--plan-root", root], { env: { RUNECODE_PROTOCOL_SCHEMAS_ROOT: fakeSchemas } }); + assert.equal(result.status, 1); + assert.match(result.stderr, /missing required runner schema/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(fakeSchemas, { recursive: true, force: true }); + } +}); + +test("cli fails closed when manifest omits one required runner schema entry", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-cli-")); + const fakeSchemas = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-schemas-")); + try { + const planPath = writePlan(root); + const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, "protocol", "schemas", "manifest.json"), "utf8")); + manifest.schema_files = manifest.schema_files.filter( + (entry) => !(entry.schema_id === "runecode.protocol.v0.RunnerResultReportResponse" && entry.schema_version === "0.1.0"), + ); + fs.cpSync(path.join(repoRoot, "protocol", "schemas"), fakeSchemas, { recursive: true }); + fs.writeFileSync(path.join(fakeSchemas, "manifest.json"), JSON.stringify(manifest, null, 2)); + + const result = runCLI(["--plan-file", planPath, "--plan-root", root], { env: { RUNECODE_PROTOCOL_SCHEMAS_ROOT: fakeSchemas } }); + assert.equal(result.status, 1); + assert.match(result.stderr, /missing required runner schema .*RunnerResultReportResponse@0\.1\.0/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(fakeSchemas, { recursive: true, force: true }); + } +}); + +test("cli fails closed when manifest runtime key points at malformed relaxed schema content", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-cli-")); + const fakeSchemas = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-schemas-")); + try { + const planPath = writePlan(root); + fs.cpSync(path.join(repoRoot, "protocol", "schemas"), fakeSchemas, { recursive: true }); + const schemaPath = path.join(fakeSchemas, "objects", "DependencyCacheHandoffRequest.schema.json"); + const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")); + schema.properties.schema_id.const = "runecode.protocol.v0.NotDependencyCacheHandoffRequest"; + delete schema.required; + schema.additionalProperties = true; + fs.writeFileSync(schemaPath, JSON.stringify(schema, null, 2)); + + const result = runCLI(["--plan-file", planPath, "--plan-root", root], { env: { RUNECODE_PROTOCOL_SCHEMAS_ROOT: fakeSchemas } }); + assert.equal(result.status, 1); + assert.match(result.stderr, /schema manifest entry .*DependencyCacheHandoffRequest\.schema\.json schema_id const .* does not match runecode\.protocol\.v0\.DependencyCacheHandoffRequest/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(fakeSchemas, { recursive: true, force: true }); + } +}); + +test("cli works from non-runner cwd when schema root comes from env", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-cli-")); + try { + const planPath = writePlan(root); + const responses = [ + { + message_type: "dependency_cache_handoff_response", + payload: { + schema_id: "runecode.protocol.v0.DependencyCacheHandoffResponse", + schema_version: "0.1.0", + request_id: dependencyHandoffRequestID("run_alpha", "sha256:" + "d".repeat(64)), + found: true, + handoff: { + schema_id: "runecode.protocol.v0.DependencyCacheHandoffMetadata", + schema_version: "0.1.0", + request_digest: { hash_alg: "sha256", hash: "d".repeat(64) }, + resolved_unit_digest: { hash_alg: "sha256", hash: "e".repeat(64) }, + manifest_digest: { hash_alg: "sha256", hash: "f".repeat(64) }, + payload_digests: [{ hash_alg: "sha256", hash: "1".repeat(64) }], + materialization_mode: "derived_read_only", + handoff_mode: "broker_internal_artifact_handoff", + }, + }, + }, + { + message_type: "runner_checkpoint_report_response", + payload: { + schema_id: "runecode.protocol.v0.RunnerCheckpointReportResponse", + schema_version: "0.1.0", + request_id: "runner-checkpoint:run_alpha:quality_lint:0", + run_id: "run_alpha", + accepted: true, + canonical_lifecycle_state: "active", + accepted_at: "2026-01-01T00:00:00Z", + idempotency_key: "runner-checkpoint:run_alpha:quality_lint:active", + }, + }, + { + message_type: "runner_result_report_response", + payload: { + schema_id: "runecode.protocol.v0.RunnerResultReportResponse", + schema_version: "0.1.0", + request_id: "runner-result:run_alpha:quality_lint:0", + run_id: "run_alpha", + accepted: true, + canonical_lifecycle_state: "completed", + accepted_at: "2026-01-01T00:00:00Z", + idempotency_key: "runner-result:run_alpha:quality_lint:ok", + }, + }, + ].map((entry) => JSON.stringify(entry)).join("\n") + "\n"; + + const result = runCLI([ + "--plan-file", planPath, + "--plan-root", root, + "--state-root", path.join(root, "state"), + "--broker-transport", "stdio", + ], { input: responses, cwd: repoRoot }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /executed 1\/1 scheduled entries/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("cli executes plan-first path over stdio transport", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-cli-")); + try { + const planPath = writePlan(root); + const responses = [ + { + message_type: "dependency_cache_handoff_response", + payload: { + schema_id: "runecode.protocol.v0.DependencyCacheHandoffResponse", + schema_version: "0.1.0", + request_id: dependencyHandoffRequestID("run_alpha", "sha256:" + "d".repeat(64)), + found: true, + handoff: { + schema_id: "runecode.protocol.v0.DependencyCacheHandoffMetadata", + schema_version: "0.1.0", + request_digest: { hash_alg: "sha256", hash: "d".repeat(64) }, + resolved_unit_digest: { hash_alg: "sha256", hash: "e".repeat(64) }, + manifest_digest: { hash_alg: "sha256", hash: "f".repeat(64) }, + payload_digests: [{ hash_alg: "sha256", hash: "1".repeat(64) }], + materialization_mode: "derived_read_only", + handoff_mode: "broker_internal_artifact_handoff", + }, + }, + }, + { + message_type: "runner_checkpoint_report_response", + payload: { + schema_id: "runecode.protocol.v0.RunnerCheckpointReportResponse", + schema_version: "0.1.0", + request_id: "runner-checkpoint:run_alpha:quality_lint:0", + run_id: "run_alpha", + accepted: true, + canonical_lifecycle_state: "active", + accepted_at: "2026-01-01T00:00:00Z", + idempotency_key: "runner-checkpoint:run_alpha:quality_lint:active", + }, + }, + { + message_type: "runner_result_report_response", + payload: { + schema_id: "runecode.protocol.v0.RunnerResultReportResponse", + schema_version: "0.1.0", + request_id: "runner-result:run_alpha:quality_lint:0", + run_id: "run_alpha", + accepted: true, + canonical_lifecycle_state: "completed", + accepted_at: "2026-01-01T00:00:00Z", + idempotency_key: "runner-result:run_alpha:quality_lint:ok", + }, + }, + ].map((entry) => JSON.stringify(entry)).join("\n") + "\n"; + + const result = runCLI([ + "--plan-file", planPath, + "--plan-root", root, + "--state-root", path.join(root, "state"), + "--broker-transport", "stdio", + ], { input: responses }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /executed 1\/1 scheduled entries/); + + const lines = result.stdout.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)); + assert.equal(lines.length, 3); + assert.equal(lines[0].message_type, "dependency_cache_handoff_request"); + assert.equal(lines[1].message_type, "runner_checkpoint_report_request"); + assert.equal(lines[2].message_type, "runner_result_report_request"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("cli exits nonzero when typed broker response rejects a report", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-cli-")); + try { + const planPath = writePlan(root); + const responses = [ + { + message_type: "dependency_cache_handoff_response", + payload: { + schema_id: "runecode.protocol.v0.DependencyCacheHandoffResponse", + schema_version: "0.1.0", + request_id: dependencyHandoffRequestID("run_alpha", "sha256:" + "d".repeat(64)), + found: true, + handoff: { + schema_id: "runecode.protocol.v0.DependencyCacheHandoffMetadata", + schema_version: "0.1.0", + request_digest: { hash_alg: "sha256", hash: "d".repeat(64) }, + resolved_unit_digest: { hash_alg: "sha256", hash: "e".repeat(64) }, + manifest_digest: { hash_alg: "sha256", hash: "f".repeat(64) }, + payload_digests: [{ hash_alg: "sha256", hash: "1".repeat(64) }], + materialization_mode: "derived_read_only", + handoff_mode: "broker_internal_artifact_handoff", + }, + }, + }, + { + message_type: "runner_checkpoint_report_response", + payload: { + schema_id: "runecode.protocol.v0.RunnerCheckpointReportResponse", + schema_version: "0.1.0", + request_id: "runner-checkpoint:run_alpha:quality_lint:0", + run_id: "run_alpha", + accepted: false, + canonical_lifecycle_state: "active", + accepted_at: "2026-01-01T00:00:00Z", + idempotency_key: "runner-checkpoint:run_alpha:quality_lint:active", + }, + }, + ].map((entry) => JSON.stringify(entry)).join("\n") + "\n"; + + const result = runCLI([ + "--plan-file", planPath, + "--plan-root", root, + "--state-root", path.join(root, "state"), + "--broker-transport", "stdio", + ], { input: responses }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /broker rejected report at lifecycle active/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/runner/scripts/runner-durable-state.test.js b/runner/scripts/runner-durable-state.test.js index 8595f828..bf2fd6b9 100644 --- a/runner/scripts/runner-durable-state.test.js +++ b/runner/scripts/runner-durable-state.test.js @@ -466,3 +466,36 @@ test("heals snapshot state from journal after crash window during wait resolutio status: "approved", }]); }); + +test("cleans up temp snapshot file when exclusive create successor rename fails", async (t) => { + const { + FileDurableStateStore, + setDurableStateStoreFSTestHooksForTesting, + } = await loadRunnerModules(); + + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-state-")); + t.after(() => { + setDurableStateStoreFSTestHooksForTesting(null); + fs.rmSync(root, { recursive: true, force: true }); + }); + + let observedTempPath = ""; + setDurableStateStoreFSTestHooksForTesting({ + async rename(from, to) { + observedTempPath = from; + const error = new Error(`simulated snapshot collision for ${to}`); + error.code = "EEXIST"; + throw error; + }, + }); + + const store = new FileDurableStateStore(root); + await assert.rejects( + () => store.bindPlanIdentity({ run_id: "run_alpha", plan_id: "plan_alpha" }), + /simulated snapshot collision/, + ); + + assert.notEqual(observedTempPath, ""); + assert.equal(fs.existsSync(observedTempPath), false); + assert.equal(fs.existsSync(path.join(root, "snapshot.v2.json")), false); +}); diff --git a/runner/scripts/runner-kernel-foundation.test.js b/runner/scripts/runner-kernel-foundation.test.js index e41d48bd..6d2ca690 100644 --- a/runner/scripts/runner-kernel-foundation.test.js +++ b/runner/scripts/runner-kernel-foundation.test.js @@ -1,3 +1,62 @@ +const assert = require("node:assert/strict"); +const { createHash } = require("node:crypto"); const test = require("node:test"); -test("runner kernel foundation coverage moved to focused test files", () => {}); +function expectedAttemptID(prefix, planID, scopeID, attemptIndex, token) { + const digest = createHash("sha256") + .update(planID) + .update("\n") + .update(scopeID) + .digest("hex"); + return `${prefix}_${token}_${digest}_${attemptIndex}`; +} + +test("boundedAttemptID trims repeated separators around normalized content", async () => { + const { boundedAttemptID } = await import("../src/runner-identifiers.ts"); + const scopeID = `${"_-".repeat(2048)}Scope_ID${"-_".repeat(2048)}`; + + assert.equal( + boundedAttemptID("step_attempt", "plan_alpha", scopeID, 1), + expectedAttemptID("step_attempt", "plan_alpha", scopeID, 1, "scope_id"), + ); +}); + +test("boundedAttemptID preserves s_ prefix when normalized token starts with a digit", async () => { + const { boundedAttemptID } = await import("../src/runner-identifiers.ts"); + const scopeID = "___9-lives___"; + + assert.equal( + boundedAttemptID("step_attempt", "plan_alpha", scopeID, 1), + expectedAttemptID("step_attempt", "plan_alpha", scopeID, 1, "s_9-lives"), + ); +}); + +test("boundedAttemptID falls back to scope when normalization removes all content", async () => { + const { boundedAttemptID } = await import("../src/runner-identifiers.ts"); + const scopeID = `${"_-".repeat(2048)}!!!${"-_".repeat(2048)}`; + + assert.equal( + boundedAttemptID("step_attempt", "plan_alpha", scopeID, 1), + expectedAttemptID("step_attempt", "plan_alpha", scopeID, 1, "scope"), + ); +}); + +test("boundedAttemptID always stays within the 128 character schema limit", async () => { + const { boundedAttemptID } = await import("../src/runner-identifiers.ts"); + const scopeID = `${"scope-".repeat(4096)}tail`; + const attemptID = boundedAttemptID("step_attempt", "plan_alpha", scopeID, 1234567890); + + assert.ok(attemptID.length <= 128, `attempt id length ${attemptID.length} exceeded 128`); + assert.match(attemptID, /^step_attempt_[a-z0-9_-]+_[0-9a-f]{64}_1234567890$/); +}); + +test("boundedAttemptID falls back to scope when prefix and suffix leave no token budget", async () => { + const { boundedAttemptID } = await import("../src/runner-identifiers.ts"); + const prefix = `prefix_${"x".repeat(120)}`; + const scopeID = "scope_id"; + + assert.equal( + boundedAttemptID(prefix, "plan_alpha", scopeID, 1), + expectedAttemptID(prefix, "plan_alpha", scopeID, 1, "scope"), + ); +}); diff --git a/runner/scripts/runner-kernel-runtime-dependency-handoff.test.js b/runner/scripts/runner-kernel-runtime-dependency-handoff.test.js index 05c5e2fe..c98c3907 100644 --- a/runner/scripts/runner-kernel-runtime-dependency-handoff.test.js +++ b/runner/scripts/runner-kernel-runtime-dependency-handoff.test.js @@ -1,4 +1,5 @@ const assert = require("node:assert/strict"); +const { createHash } = require("node:crypto"); const test = require("node:test"); const { loadRunnerModules } = require("./runner-test-helpers.js"); @@ -59,3 +60,96 @@ test("kernel fails closed when a required dependency cache handoff is missing", /required dependency cache handoff not found/, ); }); + +test("kernel emits stable unique dependency cache handoff request ids without truncation assumptions", async () => { + const { + RunnerKernel, + } = await loadRunnerModules(); + + const captured = []; + const kernel = new RunnerKernel({ + planLoader: { loadFromFile: async () => { throw new Error("unused"); }, identityOf: () => ({ run_id: "r", plan_id: "p" }) }, + durableStateStore: { + bindPlanIdentity: async () => {}, + appendRecord: async () => ({ sequence: 1 }), + readState: async () => ({ + snapshot: { + schema_version: "2", + run_id: "run_alpha", + plan_id: "plan_alpha", + last_sequence: 0, + pending_approval_waits: [], + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, + journal: [], + }), + runtimeStateRoot: () => process.cwd(), + listPendingApprovalWaits: async () => [], + }, + brokerClient: { + async requestDependencyCacheHandoff(request) { + captured.push(request); + return { + schema_id: "runecode.protocol.v0.DependencyCacheHandoffResponse", + schema_version: "0.1.0", + request_id: request.request_id, + found: true, + handoff: { + schema_id: "runecode.protocol.v0.DependencyCacheHandoffMetadata", + schema_version: "0.1.0", + request_digest: request.request_digest, + resolved_unit_digest: { hash_alg: "sha256", hash: "e".repeat(64) }, + manifest_digest: { hash_alg: "sha256", hash: "f".repeat(64) }, + payload_digests: [{ hash_alg: "sha256", hash: "1".repeat(64) }], + materialization_mode: "derived_read_only", + handoff_mode: "broker_internal_artifact_handoff", + }, + }; + }, + async sendRunnerCheckpointReport() { + return { accepted: true }; + }, + async sendRunnerResultReport() { + return { accepted: true }; + }, + }, + }); + + const longRunIDA = `run_${"shared-prefix-".repeat(12)}A`; + const longRunIDB = `run_${"shared-prefix-".repeat(12)}B`; + const digestA = `sha256:${"a".repeat(64)}`; + const digestB = `sha256:${"b".repeat(64)}`; + const expectedRequestID = (runID, requestDigest) => `dependency-handoff:${createHash("sha256").update(runID).update("\n").update(requestDigest).digest("hex")}`; + + await kernel.composeModules( + { run_id: longRunIDA, plan_id: "plan_alpha" }, + [{ name: "noop-a", async run() {} }], + [ + { request_digest: digestA, consumer_role: "workspace", required: true }, + { request_digest: digestB, consumer_role: "workspace", required: true }, + ], + ); + await kernel.composeModules( + { run_id: longRunIDA, plan_id: "plan_alpha" }, + [{ name: "noop-b", async run() {} }], + [{ request_digest: digestA, consumer_role: "workspace", required: true }], + ); + await kernel.composeModules( + { run_id: longRunIDB, plan_id: "plan_alpha" }, + [{ name: "noop-c", async run() {} }], + [{ request_digest: digestA, consumer_role: "workspace", required: true }], + ); + + const requestIDs = captured.map((request) => request.request_id); + assert.deepEqual(requestIDs, [ + expectedRequestID(longRunIDA, digestA), + expectedRequestID(longRunIDA, digestB), + expectedRequestID(longRunIDA, digestA), + expectedRequestID(longRunIDB, digestA), + ]); + assert.equal(requestIDs[0], requestIDs[2]); + assert.notEqual(requestIDs[0], requestIDs[1]); + assert.notEqual(requestIDs[0], requestIDs[3]); + assert.equal(requestIDs[0].length, "dependency-handoff:".length + 64); +}); diff --git a/runner/scripts/runner-kernel-runtime-execution.test.js b/runner/scripts/runner-kernel-runtime-execution.test.js new file mode 100644 index 00000000..27447d06 --- /dev/null +++ b/runner/scripts/runner-kernel-runtime-execution.test.js @@ -0,0 +1,322 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const { loadRunnerModules, repoRoot, validRunPlanFixture } = require("./runner-test-helpers.js"); + +test("report emitter wraps typed request envelopes", async () => { + const { + ReportEmitter, + } = await loadRunnerModules(); + + const captured = []; + const emitter = new ReportEmitter({ + async sendRunnerCheckpointReport(request) { + captured.push(request); + return { accepted: true }; + }, + async sendRunnerResultReport(request) { + captured.push(request); + return { accepted: true }; + }, + }); + + await emitter.emitCheckpointReport({ + request_id: "req-1", + identity: { + run_id: "run_alpha", + plan_id: "plan_alpha", + stage_id: "stage_alpha", + step_attempt_id: "step_attempt_alpha", + }, + report: { + lifecycle_state: "active", + checkpoint_code: "gate_running", + occurred_at: "2026-01-01T00:00:00Z", + idempotency_key: "cp-1", + }, + }); + + assert.equal(captured.length, 1); + assert.equal(captured[0].schema_id, "runecode.protocol.v0.RunnerCheckpointReportRequest"); + assert.equal(captured[0].run_id, "run_alpha"); + assert.equal(captured[0].report.schema_id, "runecode.protocol.v0.RunnerCheckpointReport"); + assert.equal(captured[0].report.step_attempt_id, "step_attempt_alpha"); +}); + +test("kernel executes scheduled gate entries and fails closed on rejected reports", async () => { + const { + ProtocolSchemaBundle, + RunPlanLoader, + RunnerKernel, + FileDurableStateStore, + } = await loadRunnerModules(); + + const schemaBundle = await ProtocolSchemaBundle.fromProtocolSchemasRoot(path.join(repoRoot, "protocol", "schemas")); + const loader = new RunPlanLoader(schemaBundle); + + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-execute-")); + try { + const store = new FileDurableStateStore(root); + const acceptedRequests = []; + const kernel = new RunnerKernel({ + planLoader: loader, + durableStateStore: store, + brokerClient: { + async requestDependencyCacheHandoff(request) { + acceptedRequests.push({ kind: "handoff", request }); + return { + schema_id: "runecode.protocol.v0.DependencyCacheHandoffResponse", + schema_version: "0.1.0", + request_id: request.request_id, + found: true, + handoff: { + schema_id: "runecode.protocol.v0.DependencyCacheHandoffMetadata", + schema_version: "0.1.0", + request_digest: request.request_digest, + resolved_unit_digest: { hash_alg: "sha256", hash: "e".repeat(64) }, + manifest_digest: { hash_alg: "sha256", hash: "f".repeat(64) }, + payload_digests: [{ hash_alg: "sha256", hash: "1".repeat(64) }], + materialization_mode: "derived_read_only", + handoff_mode: "broker_internal_artifact_handoff", + }, + }; + }, + async sendRunnerCheckpointReport(request) { + acceptedRequests.push({ kind: "checkpoint", request }); + return { accepted: true }; + }, + async sendRunnerResultReport(request) { + acceptedRequests.push({ kind: "result", request }); + return { accepted: true }; + }, + }, + }); + + const planPath = path.join(root, "runplan.json"); + fs.writeFileSync(planPath, JSON.stringify(validRunPlanFixture(), null, 2)); + + const execution = await kernel.executeScheduledWorkFromPlanFile(planPath); + assert.equal(execution.work.length, 1); + assert.equal(execution.executed.length, 1); + assert.equal(execution.executed[0].entry_id, "quality_lint"); + assert.equal(execution.executed[0].outcome.status, "ok"); + assert.deepEqual(acceptedRequests.map((entry) => entry.kind), ["handoff", "checkpoint", "result"]); + assert.equal(acceptedRequests[1].request.report.gate_lifecycle_state, "running"); + assert.equal(acceptedRequests[2].request.report.gate_lifecycle_state, "passed"); + + const rejectingKernel = new RunnerKernel({ + planLoader: loader, + durableStateStore: new FileDurableStateStore(path.join(root, "reject-state")), + brokerClient: { + async requestDependencyCacheHandoff(request) { + return { + schema_id: "runecode.protocol.v0.DependencyCacheHandoffResponse", + schema_version: "0.1.0", + request_id: request.request_id, + found: true, + handoff: { + schema_id: "runecode.protocol.v0.DependencyCacheHandoffMetadata", + schema_version: "0.1.0", + request_digest: request.request_digest, + resolved_unit_digest: { hash_alg: "sha256", hash: "e".repeat(64) }, + manifest_digest: { hash_alg: "sha256", hash: "f".repeat(64) }, + payload_digests: [{ hash_alg: "sha256", hash: "1".repeat(64) }], + materialization_mode: "derived_read_only", + handoff_mode: "broker_internal_artifact_handoff", + }, + }; + }, + async sendRunnerCheckpointReport() { + return { accepted: false, reason: "broker rejected report at lifecycle active" }; + }, + async sendRunnerResultReport() { + return { accepted: true }; + }, + }, + }); + + await assert.rejects( + () => rejectingKernel.executeScheduledWorkFromPlanFile(planPath), + /broker rejected report at lifecycle active/, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("kernel bounds derived attempt ids for long plan identities", async () => { + const { + ProtocolSchemaBundle, + RunPlanLoader, + RunnerKernel, + FileDurableStateStore, + } = await loadRunnerModules(); + + const schemaBundle = await ProtocolSchemaBundle.fromProtocolSchemasRoot(path.join(repoRoot, "protocol", "schemas")); + const loader = new RunPlanLoader(schemaBundle); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-attempt-id-")); + try { + const store = new FileDurableStateStore(root); + const captured = []; + const kernel = new RunnerKernel({ + planLoader: loader, + durableStateStore: store, + brokerClient: { + async requestDependencyCacheHandoff(request) { + return { + schema_id: "runecode.protocol.v0.DependencyCacheHandoffResponse", + schema_version: "0.1.0", + request_id: request.request_id, + found: true, + handoff: { + schema_id: "runecode.protocol.v0.DependencyCacheHandoffMetadata", + schema_version: "0.1.0", + request_digest: request.request_digest, + resolved_unit_digest: { hash_alg: "sha256", hash: "e".repeat(64) }, + manifest_digest: { hash_alg: "sha256", hash: "f".repeat(64) }, + payload_digests: [{ hash_alg: "sha256", hash: "1".repeat(64) }], + materialization_mode: "derived_read_only", + handoff_mode: "broker_internal_artifact_handoff", + }, + }; + }, + async sendRunnerCheckpointReport(request) { + captured.push(request); + return { accepted: true }; + }, + async sendRunnerResultReport(request) { + captured.push(request); + return { accepted: true }; + }, + }, + }); + + const fixture = validRunPlanFixture(); + fixture.plan_id = `plan_${"a".repeat(118)}`; + const planPath = path.join(root, "runplan.json"); + fs.writeFileSync(planPath, JSON.stringify(fixture, null, 2)); + + await kernel.executeScheduledWorkFromPlanFile(planPath); + + assert.equal(captured.length, 2); + assert.ok(captured[0].report.stage_attempt_id.length <= 128); + assert.ok(captured[0].report.step_attempt_id.length <= 128); + assert.ok(captured[0].report.gate_attempt_id.length <= 128); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("kernel fails closed when a valid plan produces no scheduled work", async () => { + const { + ProtocolSchemaBundle, + RunPlanLoader, + FileDurableStateStore, + RunnerKernel, + } = await loadRunnerModules(); + + const schemaBundle = await ProtocolSchemaBundle.fromProtocolSchemasRoot(path.join(repoRoot, "protocol", "schemas")); + const loader = new RunPlanLoader(schemaBundle); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-runtime-")); + try { + const planPath = path.join(root, "runplan.json"); + fs.writeFileSync(planPath, JSON.stringify(validRunPlanFixture(), null, 2)); + const stateRoot = path.join(root, "state"); + const store = new FileDurableStateStore(stateRoot); + await store.bindPlanIdentity({ run_id: "run_alpha", plan_id: "plan_alpha" }); + await store.enterApprovalWait({ + approval_id: "approval-block-all", + run_id: "run_alpha", + plan_id: "plan_alpha", + binding_kind: "exact_action", + bound_action_hash: "sha256:" + "a".repeat(64), + blocked_scope: { scope_kind: "run", run_id: "run_alpha", action_kind: "action_gate_override" }, + broker_correlation: { request_id: "req-block-all" }, + idempotency_key: "approval-block-all", + }); + + const kernel = new RunnerKernel({ + planLoader: loader, + durableStateStore: store, + brokerClient: { + async requestDependencyCacheHandoff() { + throw new Error("unused"); + }, + async sendRunnerCheckpointReport() { + throw new Error("unused"); + }, + async sendRunnerResultReport() { + throw new Error("unused"); + }, + }, + }); + + await assert.rejects( + () => kernel.executeScheduledWorkFromPlanFile(planPath), + /produced no scheduled work/, + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("stdio broker client validates typed transport responses", async () => { + const { + ProtocolSchemaBundle, + StdioRunnerBrokerClient, + } = await loadRunnerModules(); + + const schemaBundle = await ProtocolSchemaBundle.fromProtocolSchemasRoot(path.join(repoRoot, "protocol", "schemas")); + const { PassThrough } = require("node:stream"); + const input = new PassThrough(); + const output = new PassThrough(); + const writes = []; + + output.on("data", (chunk) => { + writes.push(chunk.toString("utf8")); + }); + + const client = new StdioRunnerBrokerClient({ + schemaBundle, + input, + output, + }); + + input.end(`${JSON.stringify({ + message_type: "runner_checkpoint_report_response", + payload: { + schema_id: "runecode.protocol.v0.RunnerCheckpointReportResponse", + schema_version: "0.1.0", + request_id: "req-stdio-1", + run_id: "run_alpha", + accepted: true, + canonical_lifecycle_state: "active", + accepted_at: "2026-01-01T00:00:00Z", + idempotency_key: "cp-stdio-1", + }, + })}\n`); + + const ack = await client.sendRunnerCheckpointReport({ + schema_id: "runecode.protocol.v0.RunnerCheckpointReportRequest", + schema_version: "0.1.0", + request_id: "req-stdio-1", + run_id: "run_alpha", + report: { + schema_id: "runecode.protocol.v0.RunnerCheckpointReport", + schema_version: "0.1.0", + lifecycle_state: "active", + checkpoint_code: "quality", + occurred_at: "2026-01-01T00:00:00Z", + idempotency_key: "cp-stdio-1", + }, + }); + + assert.deepEqual(ack, { accepted: true }); + assert.equal(writes.length, 1); + const outbound = JSON.parse(writes[0]); + assert.equal(outbound.message_type, "runner_checkpoint_report_request"); + assert.equal(outbound.payload.schema_id, "runecode.protocol.v0.RunnerCheckpointReportRequest"); +}); diff --git a/runner/scripts/runner-kernel-runtime-noop-client.test.js b/runner/scripts/runner-kernel-runtime-noop-client.test.js deleted file mode 100644 index 24b7e10e..00000000 --- a/runner/scripts/runner-kernel-runtime-noop-client.test.js +++ /dev/null @@ -1,43 +0,0 @@ -const assert = require("node:assert/strict"); -const test = require("node:test"); - -const { loadRunnerModules } = require("./runner-test-helpers.js"); - -test("noop broker client returns unaccepted acknowledgements", async () => { - const { - NoopRunnerBrokerClient, - } = await loadRunnerModules(); - - const client = new NoopRunnerBrokerClient(); - const checkpoint = await client.sendRunnerCheckpointReport({ - schema_id: "runecode.protocol.v0.RunnerCheckpointReportRequest", - schema_version: "0.1.0", - request_id: "noop-checkpoint", - run_id: "run_alpha", - report: { - schema_id: "runecode.protocol.v0.RunnerCheckpointReport", - schema_version: "0.1.0", - lifecycle_state: "active", - checkpoint_code: "gate_running", - occurred_at: "2026-01-01T00:00:00Z", - idempotency_key: "noop-cp-1", - }, - }); - const result = await client.sendRunnerResultReport({ - schema_id: "runecode.protocol.v0.RunnerResultReportRequest", - schema_version: "0.1.0", - request_id: "noop-result", - run_id: "run_alpha", - report: { - schema_id: "runecode.protocol.v0.RunnerResultReport", - schema_version: "0.1.0", - lifecycle_state: "completed", - result_code: "step_succeeded", - occurred_at: "2026-01-01T00:00:00Z", - idempotency_key: "noop-result-1", - }, - }); - - assert.deepEqual(checkpoint, { accepted: false, reason: "broker client not configured" }); - assert.deepEqual(result, { accepted: false, reason: "broker client not configured" }); -}); diff --git a/runner/scripts/runner-kernel-runtime.test.js b/runner/scripts/runner-kernel-runtime.test.js index 740fecc5..a67a8177 100644 --- a/runner/scripts/runner-kernel-runtime.test.js +++ b/runner/scripts/runner-kernel-runtime.test.js @@ -356,6 +356,22 @@ test("fails closed when resume resolution binding/hash does not match pending wa const kernelWrongHash = new RunnerKernel({ planLoader: loader, durableStateStore: store, + brokerClient: { + async requestDependencyCacheHandoff(request) { + return { + schema_id: "runecode.protocol.v0.DependencyCacheHandoffResponse", + schema_version: "0.1.0", + request_id: request.request_id, + found: false, + }; + }, + async sendRunnerCheckpointReport() { + return { accepted: false, reason: "unused" }; + }, + async sendRunnerResultReport() { + return { accepted: false, reason: "unused" }; + }, + }, approvalWaitResolver: { async resolve(wait) { return { @@ -378,6 +394,22 @@ test("fails closed when resume resolution binding/hash does not match pending wa const kernelStalePlan = new RunnerKernel({ planLoader: loader, durableStateStore: store, + brokerClient: { + async requestDependencyCacheHandoff(request) { + return { + schema_id: "runecode.protocol.v0.DependencyCacheHandoffResponse", + schema_version: "0.1.0", + request_id: request.request_id, + found: false, + }; + }, + async sendRunnerCheckpointReport() { + return { accepted: false, reason: "unused" }; + }, + async sendRunnerResultReport() { + return { accepted: false, reason: "unused" }; + }, + }, approvalWaitResolver: { async resolve(wait) { return { @@ -494,6 +526,22 @@ test("kernel resumeApprovalWaits returns explicit cleared statuses", async (t) = const kernel = new RunnerKernel({ planLoader: loader, durableStateStore: store, + brokerClient: { + async requestDependencyCacheHandoff(request) { + return { + schema_id: "runecode.protocol.v0.DependencyCacheHandoffResponse", + schema_version: "0.1.0", + request_id: request.request_id, + found: false, + }; + }, + async sendRunnerCheckpointReport() { + return { accepted: false, reason: "unused" }; + }, + async sendRunnerResultReport() { + return { accepted: false, reason: "unused" }; + }, + }, approvalWaitResolver: { async resolve(wait) { return { @@ -515,68 +563,6 @@ test("kernel resumeApprovalWaits returns explicit cleared statuses", async (t) = }]); }); -test("report emitter wraps typed request envelopes", async () => { - const { - ReportEmitter, - } = await loadRunnerModules(); - - const captured = []; - const emitter = new ReportEmitter({ - async sendRunnerCheckpointReport(request) { - captured.push(request); - return { accepted: true }; - }, - async sendRunnerResultReport(request) { - captured.push(request); - return { accepted: true }; - }, - }); - - await emitter.emitCheckpointReport({ - request_id: "req-1", - identity: { - run_id: "run_alpha", - plan_id: "plan_alpha", - stage_id: "stage_alpha", - step_attempt_id: "step_attempt_alpha", - }, - report: { - lifecycle_state: "active", - checkpoint_code: "gate_running", - occurred_at: "2026-01-01T00:00:00Z", - idempotency_key: "cp-1", - }, - }); - - assert.equal(captured.length, 1); - assert.equal(captured[0].schema_id, "runecode.protocol.v0.RunnerCheckpointReportRequest"); - assert.equal(captured[0].run_id, "run_alpha"); - assert.equal(captured[0].report.schema_id, "runecode.protocol.v0.RunnerCheckpointReport"); - assert.equal(captured[0].report.step_attempt_id, "step_attempt_alpha"); -}); - -test("noop broker client exposes dependency cache handoff seam", async () => { - const { - NoopRunnerBrokerClient, - } = await loadRunnerModules(); - - const client = new NoopRunnerBrokerClient(); - const response = await client.requestDependencyCacheHandoff({ - schema_id: "runecode.protocol.v0.DependencyCacheHandoffRequest", - schema_version: "0.1.0", - request_id: "noop-handoff", - request_digest: { hash_alg: "sha256", hash: "a".repeat(64) }, - consumer_role: "workspace", - }); - - assert.deepEqual(response, { - schema_id: "runecode.protocol.v0.DependencyCacheHandoffResponse", - schema_version: "0.1.0", - request_id: "noop-handoff", - found: false, - }); -}); - test("runtime seam idempotency ignores payload detail key order and writes private file mode", async (t) => { if (process.platform === "win32") { t.skip("permission bit checks are platform-specific"); @@ -787,9 +773,34 @@ test("kernel composes modules with plan-bound identity", async () => { ]); assert.equal(handoffRequests.length, 1); - assert.match(handoffRequests[0].request_id, /^dependency-handoff:run_alpha:[a-f0-9]{12}$/); + assert.match(handoffRequests[0].request_id, /^dependency-handoff:[a-f0-9]{64}$/); assert.equal(handoffRequests[0].consumer_role, "workspace"); assert.equal(calls.length, 1); assert.equal(calls[0].kind, "park"); assert.equal(calls[0].input.identity.run_id, "run_alpha"); }); + +test("kernel constructor fails closed without broker client", async () => { + const { + ProtocolSchemaBundle, + RunPlanLoader, + RunnerKernel, + FileDurableStateStore, + MissingRunnerBrokerTransportError, + } = await loadRunnerModules(); + + const schemaBundle = await ProtocolSchemaBundle.fromProtocolSchemasRoot(path.join(repoRoot, "protocol", "schemas")); + const loader = new RunPlanLoader(schemaBundle); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "runecode-runner-kernel-")); + const store = new FileDurableStateStore(root); + try { + assert.throws(() => { + new RunnerKernel({ + planLoader: loader, + durableStateStore: store, + }); + }, MissingRunnerBrokerTransportError); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/runner/src/README.md b/runner/src/README.md index 91a5feaf..5fd53d3d 100644 --- a/runner/src/README.md +++ b/runner/src/README.md @@ -14,6 +14,7 @@ The runner remains thin and seamful: - `report-emitter.ts`: typed checkpoint/result request seam - `broker-client.ts`: broker transport abstraction seam - `kernel.ts`: composition root +- `cli.ts`: normal product runner launch entrypoint (`npm run start -- --plan-file `) ## Trust Boundary Rules @@ -33,3 +34,34 @@ broker-compiled immutable plan. The append-only journal is authoritative for recovery. Snapshot files are a cache that can be healed from journal replay after a crash rather than becoming the source of truth. + +## Supported Product Launch Path + +For the supported beta slice, launch the runner via: + +- `npm run start -- --plan-file --plan-root --broker-transport stdio` + +This path is intentionally plan-first and requires explicit broker transport +wiring in the process environment. The kernel fails closed if a broker transport +is missing, a typed transport response is absent, or the broker rejects emitted +checkpoint/result reports. + +The trusted broker launcher must provide `RUNECODE_PROTOCOL_SCHEMAS_ROOT` as an +absolute path to the checked-in `protocol/schemas` bundle and launch the runner +from the `runner/` working directory. The runner also confines `--plan-file` and +`--state-root` under `--plan-root` so the untrusted process cannot be steered +toward arbitrary host paths through product launch arguments. + +### Minimal local transport seam + +The supported untrusted integration seam is typed newline-delimited JSON over +stdin/stdout: + +- runner writes request envelopes with `message_type` plus typed protocol + payloads (`DependencyCacheHandoffRequest`, `RunnerCheckpointReportRequest`, + `RunnerResultReportRequest`) +- broker-side launcher/integration must answer with matching typed response + envelopes validated against protocol schemas before the runner accepts them + +This keeps the runner transport concrete for product launch without granting it +planning or authorization authority. diff --git a/runner/src/broker-client.ts b/runner/src/broker-client.ts index ecde06a2..80244657 100644 --- a/runner/src/broker-client.ts +++ b/runner/src/broker-client.ts @@ -1,43 +1,302 @@ /** * Broker client seam for runner report delivery. * - * This abstraction isolates transport details while preserving typed protocol - * request shapes. + * The runner stays transport-agnostic at the kernel boundary, while supported + * product execution uses typed transport messages validated against protocol + * schemas before crossing the trust boundary. */ +import { createInterface } from "node:readline/promises"; +import type { Interface as ReadLineInterface } from "node:readline"; +import type { Readable, Writable } from "node:stream"; +import { ProtocolSchemaBundle } from "./protocol-schema-bundle.ts"; +import { + DEPENDENCY_CACHE_HANDOFF_REQUEST_SCHEMA_ID, + DEPENDENCY_CACHE_HANDOFF_RESPONSE_SCHEMA_ID, + RUNNER_CHECKPOINT_REPORT_REQUEST_SCHEMA_ID, + RUNNER_CHECKPOINT_REPORT_RESPONSE_SCHEMA_ID, + RUNNER_CONTRACT_SCHEMA_VERSION, + RUNNER_RESULT_REPORT_REQUEST_SCHEMA_ID, + RUNNER_RESULT_REPORT_RESPONSE_SCHEMA_ID, + type DependencyCacheHandoffRequest, + type DependencyCacheHandoffResponse, + type RunnerCheckpointReportRequest, + type RunnerCheckpointReportResponse, + type RunnerResultReportRequest, + type RunnerResultReportResponse, +} from "./contracts.ts"; + export type BrokerAcknowledge = { accepted: boolean; reason?: string; }; -import type { - DependencyCacheHandoffRequest, - DependencyCacheHandoffResponse, - RunnerCheckpointReportRequest, - RunnerResultReportRequest, -} from "./contracts.ts"; - export type RunnerBrokerClient = { requestDependencyCacheHandoff(request: DependencyCacheHandoffRequest): Promise; sendRunnerCheckpointReport(request: RunnerCheckpointReportRequest): Promise; sendRunnerResultReport(request: RunnerResultReportRequest): Promise; + close(): void; }; -export class NoopRunnerBrokerClient implements RunnerBrokerClient { +type StdioBrokerTransportMessage = { + message_type: "dependency_cache_handoff_request" | "runner_checkpoint_report_request" | "runner_result_report_request"; + payload: DependencyCacheHandoffRequest | RunnerCheckpointReportRequest | RunnerResultReportRequest; +}; + +type StdioBrokerTransportResponse = { + message_type: + | "dependency_cache_handoff_response" + | "runner_checkpoint_report_response" + | "runner_result_report_response"; + payload: DependencyCacheHandoffResponse | RunnerCheckpointReportResponse | RunnerResultReportResponse; +}; + +type StdioRunnerBrokerClientOptions = { + schemaBundle: ProtocolSchemaBundle; + input?: Readable; + output?: Writable; +}; + +export class RunnerBrokerTransportError extends Error { + constructor(message: string) { + super(message); + this.name = "RunnerBrokerTransportError"; + } +} + +export class MissingRunnerBrokerTransportError extends Error { + constructor() { + super("runner broker transport is required for supported execution path"); + this.name = "MissingRunnerBrokerTransportError"; + } +} + +const brokerLifecycleStates = new Set(["pending", "starting", "active", "blocked", "recovering", "completed", "failed", "cancelled"]); + +export class StdioRunnerBrokerClient implements RunnerBrokerClient { + private readonly schemaBundle: ProtocolSchemaBundle; + + private readonly input: Readable; + + private readonly output: Writable; + + private readonly lines: ReadLineInterface; + + private readonly lineIterator: AsyncIterator; + + private responseChain: Promise = Promise.resolve(); + + private poisoned: Error | undefined; + + constructor(options: StdioRunnerBrokerClientOptions) { + this.schemaBundle = options.schemaBundle; + this.input = options.input ?? process.stdin; + this.output = options.output ?? process.stdout; + this.lines = createInterface({ input: this.input }); + this.lineIterator = this.lines[Symbol.asyncIterator](); + } + async requestDependencyCacheHandoff(request: DependencyCacheHandoffRequest): Promise { + return this.roundTrip( + { + message_type: "dependency_cache_handoff_request", + payload: request, + }, + { + expectedMessageType: "dependency_cache_handoff_response", + responseSchemaId: DEPENDENCY_CACHE_HANDOFF_RESPONSE_SCHEMA_ID, + responseSchemaVersion: RUNNER_CONTRACT_SCHEMA_VERSION, + }, + ); + } + + async sendRunnerCheckpointReport(request: RunnerCheckpointReportRequest): Promise { + const response = await this.roundTrip( + { + message_type: "runner_checkpoint_report_request", + payload: request, + }, + { + expectedMessageType: "runner_checkpoint_report_response", + responseSchemaId: RUNNER_CHECKPOINT_REPORT_RESPONSE_SCHEMA_ID, + responseSchemaVersion: RUNNER_CONTRACT_SCHEMA_VERSION, + }, + ); + return responseToAcknowledge(response); + } + + async sendRunnerResultReport(request: RunnerResultReportRequest): Promise { + const response = await this.roundTrip( + { + message_type: "runner_result_report_request", + payload: request, + }, + { + expectedMessageType: "runner_result_report_response", + responseSchemaId: RUNNER_RESULT_REPORT_RESPONSE_SCHEMA_ID, + responseSchemaVersion: RUNNER_CONTRACT_SCHEMA_VERSION, + }, + ); + return responseToAcknowledge(response); + } + + close(): void { + this.lines.close(); + } + + private async roundTrip( + request: StdioBrokerTransportMessage, + expectation: { + expectedMessageType: StdioBrokerTransportResponse["message_type"]; + responseSchemaId: string; + responseSchemaVersion: string; + }, + ): Promise { + const next = this.responseChain.then(async () => { + if (this.poisoned) { + throw this.poisoned; + } + this.validateOutgoingRequest(request); + await this.writeMessage(request); + const response = await this.readResponse(); + if (response.message_type !== expectation.expectedMessageType) { + throw new RunnerBrokerTransportError( + `broker transport returned ${response.message_type}; expected ${expectation.expectedMessageType}`, + ); + } + const validation = this.schemaBundle.validateByRuntimeKey( + expectation.responseSchemaId, + expectation.responseSchemaVersion, + response.payload, + ); + if (!validation.ok) { + throw new RunnerBrokerTransportError( + `broker transport response schema validation failed: ${validation.reason}`, + ); + } + return response.payload as T; + }); + this.responseChain = next.then( + () => undefined, + (error) => { + this.poisonTransport(error instanceof Error ? error : new RunnerBrokerTransportError(String(error))); + }, + ); + return next; + } + + private poisonTransport(error: Error): void { + if (!this.poisoned) { + this.poisoned = error; + this.lines.close(); + } + } + + private validateOutgoingRequest(request: StdioBrokerTransportMessage): void { + const runtimeKey = (() => { + switch (request.message_type) { + case "dependency_cache_handoff_request": + return DEPENDENCY_CACHE_HANDOFF_REQUEST_SCHEMA_ID; + case "runner_checkpoint_report_request": + return RUNNER_CHECKPOINT_REPORT_REQUEST_SCHEMA_ID; + case "runner_result_report_request": + return RUNNER_RESULT_REPORT_REQUEST_SCHEMA_ID; + } + })(); + const validation = this.schemaBundle.validateByRuntimeKey(runtimeKey, RUNNER_CONTRACT_SCHEMA_VERSION, request.payload); + if (!validation.ok) { + throw new RunnerBrokerTransportError(`broker request schema validation failed: ${validation.reason}`); + } + } + + private async writeMessage(message: StdioBrokerTransportMessage): Promise { + const encoded = `${JSON.stringify(message)}\n`; + await new Promise((resolve, reject) => { + this.output.write(encoded, "utf8", (error) => { + if (error) { + reject(new RunnerBrokerTransportError(`broker transport write failed: ${error.message}`)); + return; + } + resolve(); + }); + }); + } + + private async readResponse(): Promise { + const { value, done } = await this.lineIterator.next(); + if (done || value === undefined) { + throw new RunnerBrokerTransportError("broker transport closed before returning a typed response"); + } + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + throw new RunnerBrokerTransportError(`broker transport response parse failed: ${(error as Error).message}`); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new RunnerBrokerTransportError("broker transport response must be an object"); + } + const record = parsed as Record; + const messageType = record.message_type; + if ( + messageType !== "dependency_cache_handoff_response" + && messageType !== "runner_checkpoint_report_response" + && messageType !== "runner_result_report_response" + ) { + throw new RunnerBrokerTransportError("broker transport response message_type is invalid"); + } + const payload = record.payload; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new RunnerBrokerTransportError("broker transport response payload must be an object"); + } return { - schema_id: "runecode.protocol.v0.DependencyCacheHandoffResponse", - schema_version: "0.1.0", - request_id: request.request_id, - found: false, + message_type: messageType, + payload: payload as DependencyCacheHandoffResponse | RunnerCheckpointReportResponse | RunnerResultReportResponse, }; } +} + +export function createSupportedRunnerBrokerClient(options: { + transport: "stdio" | "none"; + schemaBundle: ProtocolSchemaBundle; + input?: Readable; + output?: Writable; +}): RunnerBrokerClient { + if (options.transport === "stdio") { + return new StdioRunnerBrokerClient({ + schemaBundle: options.schemaBundle, + input: options.input, + output: options.output, + }); + } + return new MissingRunnerBrokerClient(); +} - async sendRunnerCheckpointReport(_request: RunnerCheckpointReportRequest): Promise { - return { accepted: false, reason: "broker client not configured" }; +class MissingRunnerBrokerClient implements RunnerBrokerClient { + requestDependencyCacheHandoff(_request: DependencyCacheHandoffRequest): Promise { + throw new MissingRunnerBrokerTransportError(); } - async sendRunnerResultReport(_request: RunnerResultReportRequest): Promise { - return { accepted: false, reason: "broker client not configured" }; + sendRunnerCheckpointReport(_request: RunnerCheckpointReportRequest): Promise { + throw new MissingRunnerBrokerTransportError(); + } + + sendRunnerResultReport(_request: RunnerResultReportRequest): Promise { + throw new MissingRunnerBrokerTransportError(); + } + + close(): void {} +} + +function responseToAcknowledge(response: RunnerCheckpointReportResponse | RunnerResultReportResponse): BrokerAcknowledge { + if (response.accepted) { + return { accepted: true }; } + const lifecycle = brokerLifecycleStates.has(response.canonical_lifecycle_state) + ? response.canonical_lifecycle_state + : "unknown"; + return { + accepted: false, + reason: `broker rejected report at lifecycle ${lifecycle}`, + }; } diff --git a/runner/src/cli.ts b/runner/src/cli.ts new file mode 100644 index 00000000..b6cf0a18 --- /dev/null +++ b/runner/src/cli.ts @@ -0,0 +1,188 @@ +/** + * Product runner entrypoint for supported execution path. + * + * Usage: + * node --experimental-strip-types src/cli.ts --plan-file + * + * This entrypoint intentionally requires an explicit broker transport + * implementation for supported execution mode. + */ + +import { ProtocolSchemaBundle } from "./protocol-schema-bundle.ts"; +import { RunPlanLoader } from "./run-plan.ts"; +import { FileDurableStateStore } from "./durable-state.ts"; +import { RunnerKernel } from "./kernel.ts"; +import { createSupportedRunnerBrokerClient } from "./broker-client.ts"; +import { existsSync, realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; +import { ExecutorAdapterRegistry, MinimalGateExecutorAdapter } from "./executor-adapter.ts"; +import { + DEPENDENCY_CACHE_HANDOFF_REQUEST_SCHEMA_ID, + DEPENDENCY_CACHE_HANDOFF_RESPONSE_SCHEMA_ID, + RUNNER_CHECKPOINT_REPORT_REQUEST_SCHEMA_ID, + RUNNER_CHECKPOINT_REPORT_RESPONSE_SCHEMA_ID, + RUNNER_CONTRACT_SCHEMA_VERSION, + RUNNER_RESULT_REPORT_REQUEST_SCHEMA_ID, + RUNNER_RESULT_REPORT_RESPONSE_SCHEMA_ID, +} from "./contracts.ts"; + +type RunnerCLIOptions = { + planFile: string; + planRoot: string; + stateRoot: string; + protocolSchemasRoot: string; + brokerTransport: "stdio" | "none"; +}; + +function parseArgs(argv: string[]): RunnerCLIOptions { + let planFile = ""; + let planRoot = process.cwd(); + let stateRoot = ".runecode/runner-state"; + let brokerTransport: "stdio" | "none" = "none"; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--plan-file") { + planFile = readFlagValue(argv, i, "--plan-file"); + i += 1; + continue; + } + if (arg === "--plan-root") { + planRoot = readFlagValue(argv, i, "--plan-root"); + i += 1; + continue; + } + if (arg === "--state-root") { + stateRoot = readFlagValue(argv, i, "--state-root"); + i += 1; + continue; + } + if (arg === "--protocol-schemas-root") { + throw new Error("--protocol-schemas-root is not supported for product runner execution"); + } + if (arg === "--broker-transport") { + const value = readFlagValue(argv, i, "--broker-transport"); + if (value !== "stdio" && value !== "none") { + throw new Error("--broker-transport must be stdio or none"); + } + brokerTransport = value; + i += 1; + continue; + } + throw new Error(`unknown argument: ${arg}`); + } + if (!planFile.trim()) { + throw new Error("--plan-file is required"); + } + const resolvedPlanRoot = resolve(planRoot); + const confinedPlanRoot = canonicalizeConfinedRoot(resolvedPlanRoot, "--plan-root"); + return { + planFile: resolveConfinedPath(confinedPlanRoot, planFile, "--plan-file"), + planRoot: confinedPlanRoot, + stateRoot: resolveConfinedPath(confinedPlanRoot, stateRoot, "--state-root"), + protocolSchemasRoot: defaultProtocolSchemasRoot(), + brokerTransport, + }; +} + +function readFlagValue(argv: string[], index: number, flag: string): string { + const value = argv[index + 1] ?? ""; + if (!value || value.startsWith("--")) { + throw new Error(`${flag} requires a value`); + } + return value; +} + +function defaultProtocolSchemasRoot(): string { + const configured = process.env.RUNECODE_PROTOCOL_SCHEMAS_ROOT ?? ""; + if (!configured.trim()) { + throw new Error("RUNECODE_PROTOCOL_SCHEMAS_ROOT is required for product runner execution"); + } + if (!isAbsolute(configured)) { + throw new Error("RUNECODE_PROTOCOL_SCHEMAS_ROOT must be absolute"); + } + return resolve(configured); +} + +function assertRequiredRunnerSchemasPresent(bundle: ProtocolSchemaBundle): void { + for (const [schemaID, schemaVersion] of [ + [DEPENDENCY_CACHE_HANDOFF_REQUEST_SCHEMA_ID, RUNNER_CONTRACT_SCHEMA_VERSION], + [DEPENDENCY_CACHE_HANDOFF_RESPONSE_SCHEMA_ID, RUNNER_CONTRACT_SCHEMA_VERSION], + [RUNNER_CHECKPOINT_REPORT_REQUEST_SCHEMA_ID, RUNNER_CONTRACT_SCHEMA_VERSION], + [RUNNER_CHECKPOINT_REPORT_RESPONSE_SCHEMA_ID, RUNNER_CONTRACT_SCHEMA_VERSION], + [RUNNER_RESULT_REPORT_REQUEST_SCHEMA_ID, RUNNER_CONTRACT_SCHEMA_VERSION], + [RUNNER_RESULT_REPORT_RESPONSE_SCHEMA_ID, RUNNER_CONTRACT_SCHEMA_VERSION], + ] as const) { + if (!bundle.hasRuntimeKey(schemaID, schemaVersion)) { + throw new Error(`RUNECODE_PROTOCOL_SCHEMAS_ROOT is missing required runner schema ${schemaID}@${schemaVersion}`); + } + } +} + +function resolveConfinedPath(root: string, value: string, label: string): string { + const resolved = isAbsolute(value) ? resolve(value) : resolve(root, value); + const canonicalResolved = canonicalizeExistingPathPrefix(resolved); + const rel = relative(root, canonicalResolved); + if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) { + return resolved; + } + throw new Error(`${label} must resolve inside --plan-root`); +} + +function canonicalizeConfinedRoot(root: string, label: string): string { + try { + return realpathSync(root); + } catch { + throw new Error(`${label} must exist`); + } +} + +function canonicalizeExistingPathPrefix(pathValue: string): string { + let current = pathValue; + const suffix: string[] = []; + for (;;) { + if (existsSync(current)) { + let canonical = realpathSync(current); + while (suffix.length > 0) { + canonical = resolve(canonical, suffix.pop() ?? ""); + } + return canonical; + } + const parent = dirname(current); + if (parent === current) { + throw new Error(`path does not exist: ${pathValue}`); + } + suffix.push(basename(current)); + current = parent; + } +} + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + const schemas = await ProtocolSchemaBundle.fromProtocolSchemasRoot(options.protocolSchemasRoot); + assertRequiredRunnerSchemasPresent(schemas); + const loader = new RunPlanLoader(schemas); + const store = new FileDurableStateStore(options.stateRoot); + const brokerClient = createSupportedRunnerBrokerClient({ + transport: options.brokerTransport, + schemaBundle: schemas, + }); + const executorAdapterRegistry = new ExecutorAdapterRegistry(); + executorAdapterRegistry.register("gate", new MinimalGateExecutorAdapter()); + const kernel = new RunnerKernel({ + planLoader: loader, + durableStateStore: store, + brokerClient, + executorAdapterRegistry, + }); + const execution = await kernel.executeScheduledWorkFromPlanFile(options.planFile); + brokerClient.close(); + process.stderr.write( + `executed ${execution.executed.length}/${execution.work.length} scheduled entries\n`, + ); +} + +main().catch((err) => { + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(`${message}\n`); + process.exitCode = 1; +}); diff --git a/runner/src/contracts.ts b/runner/src/contracts.ts index 38095c05..8a37153f 100644 --- a/runner/src/contracts.ts +++ b/runner/src/contracts.ts @@ -9,6 +9,8 @@ export const RUNNER_CHECKPOINT_REPORT_SCHEMA_ID = "runecode.protocol.v0.RunnerCh export const RUNNER_RESULT_REPORT_SCHEMA_ID = "runecode.protocol.v0.RunnerResultReport"; export const RUNNER_CHECKPOINT_REPORT_REQUEST_SCHEMA_ID = "runecode.protocol.v0.RunnerCheckpointReportRequest"; export const RUNNER_RESULT_REPORT_REQUEST_SCHEMA_ID = "runecode.protocol.v0.RunnerResultReportRequest"; +export const RUNNER_CHECKPOINT_REPORT_RESPONSE_SCHEMA_ID = "runecode.protocol.v0.RunnerCheckpointReportResponse"; +export const RUNNER_RESULT_REPORT_RESPONSE_SCHEMA_ID = "runecode.protocol.v0.RunnerResultReportResponse"; export const DEPENDENCY_CACHE_HANDOFF_REQUEST_SCHEMA_ID = "runecode.protocol.v0.DependencyCacheHandoffRequest"; export const DEPENDENCY_CACHE_HANDOFF_RESPONSE_SCHEMA_ID = "runecode.protocol.v0.DependencyCacheHandoffResponse"; export const DEPENDENCY_CACHE_HANDOFF_METADATA_SCHEMA_ID = "runecode.protocol.v0.DependencyCacheHandoffMetadata"; @@ -99,6 +101,28 @@ export type RunnerResultReportRequest = { report: RunnerResultReport; }; +export type RunnerCheckpointReportResponse = { + schema_id: typeof RUNNER_CHECKPOINT_REPORT_RESPONSE_SCHEMA_ID; + schema_version: typeof RUNNER_CONTRACT_SCHEMA_VERSION; + request_id: string; + run_id: string; + accepted: boolean; + canonical_lifecycle_state: "pending" | "starting" | "active" | "blocked" | "recovering" | "completed" | "failed" | "cancelled"; + accepted_at: string; + idempotency_key: string; +}; + +export type RunnerResultReportResponse = { + schema_id: typeof RUNNER_RESULT_REPORT_RESPONSE_SCHEMA_ID; + schema_version: typeof RUNNER_CONTRACT_SCHEMA_VERSION; + request_id: string; + run_id: string; + accepted: boolean; + canonical_lifecycle_state: "pending" | "starting" | "active" | "blocked" | "recovering" | "completed" | "failed" | "cancelled"; + accepted_at: string; + idempotency_key: string; +}; + export type DependencyCacheHandoffRequest = { schema_id: typeof DEPENDENCY_CACHE_HANDOFF_REQUEST_SCHEMA_ID; schema_version: typeof RUNNER_CONTRACT_SCHEMA_VERSION; diff --git a/runner/src/durable-state.ts b/runner/src/durable-state.ts index 3bd0af8b..0df5bccc 100644 --- a/runner/src/durable-state.ts +++ b/runner/src/durable-state.ts @@ -86,4 +86,4 @@ export { healSnapshotFromJournal, snapshotNeedsRewrite, } from "./durable-state/replay.ts"; -export { FileDurableStateStore } from "./durable-state/store.ts"; +export { FileDurableStateStore, setDurableStateStoreFSTestHooksForTesting } from "./durable-state/store.ts"; diff --git a/runner/src/durable-state/store.ts b/runner/src/durable-state/store.ts index fc19d712..7474dda9 100644 --- a/runner/src/durable-state/store.ts +++ b/runner/src/durable-state/store.ts @@ -1,4 +1,5 @@ -import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { appendFile, mkdir, open, readFile, rename, rm } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; import path from "node:path"; import type { RunnerPlanIdentity } from "../run-plan.ts"; import { @@ -26,6 +27,24 @@ const durableStateWriteLocks = new Map>(); const PRIVATE_STATE_DIR_MODE = 0o700; const PRIVATE_STATE_FILE_MODE = 0o600; +type DurableStateStoreFS = { + open: typeof open; + rename: typeof rename; + rm: typeof rm; +}; + +const durableStateStoreFS: DurableStateStoreFS = { + open, + rename, + rm, +}; + +export function setDurableStateStoreFSTestHooksForTesting(hooks: Partial | null): void { + durableStateStoreFS.open = hooks?.open ?? open; + durableStateStoreFS.rename = hooks?.rename ?? rename; + durableStateStoreFS.rm = hooks?.rm ?? rm; +} + export class FileDurableStateStore { private readonly stateRoot: string; @@ -272,12 +291,21 @@ export class FileDurableStateStore { } private async writeSnapshot(snapshot: DurableSnapshot): Promise { - const tempPath = `${this.snapshotPath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`; - await writeFile(tempPath, `${JSON.stringify(snapshot, null, 2)}\n`, { - encoding: "utf8", - mode: PRIVATE_STATE_FILE_MODE, - }); - await rename(tempPath, this.snapshotPath); + const tempPath = `${this.snapshotPath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`; + const file = await durableStateStoreFS.open(tempPath, "wx", PRIVATE_STATE_FILE_MODE); + try { + await file.writeFile(`${JSON.stringify(snapshot, null, 2)}\n`, { + encoding: "utf8", + }); + } finally { + await file.close(); + } + try { + await durableStateStoreFS.rename(tempPath, this.snapshotPath); + } catch (error) { + await durableStateStoreFS.rm(tempPath, { force: true }).catch(() => {}); + throw error; + } } private async withWriteLock(operation: () => Promise): Promise { diff --git a/runner/src/executor-adapter.ts b/runner/src/executor-adapter.ts index ef25baca..d9f603fb 100644 --- a/runner/src/executor-adapter.ts +++ b/runner/src/executor-adapter.ts @@ -5,15 +5,21 @@ * policy-agnostic: authorization remains broker-owned. */ +import type { DependencyCacheHandoffMetadata, PlanBoundExecutionIdentity } from "./contracts.ts"; import type { RunnerPlanEntry } from "./run-plan.ts"; export type ExecutionOutcome = { status: "ok" | "failed"; details?: Record; + failure_reason_code?: string; }; export type ExecutorAdapter = { - execute(entry: RunnerPlanEntry): Promise; + execute(input: { + identity: PlanBoundExecutionIdentity; + entry: RunnerPlanEntry; + dependency_cache_handoffs: DependencyCacheHandoffMetadata[]; + }): Promise; }; export class ExecutorAdapterRegistry { @@ -27,3 +33,26 @@ export class ExecutorAdapterRegistry { return this.adaptersByKind.get(entryKind) ?? null; } } + +export class MinimalGateExecutorAdapter implements ExecutorAdapter { + async execute(input: { + identity: PlanBoundExecutionIdentity; + entry: RunnerPlanEntry; + dependency_cache_handoffs: DependencyCacheHandoffMetadata[]; + }): Promise { + return { + status: "ok", + details: { + executor_binding_id: input.entry.executor_binding_id, + gate_id: gateString(input.entry.gate.gate_id), + gate_kind: gateString(input.entry.gate.gate_kind), + handoff_count: input.dependency_cache_handoffs.length, + step_id: input.identity.step_id, + }, + }; + } +} + +function gateString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} diff --git a/runner/src/index.ts b/runner/src/index.ts index 31f88d13..53a72403 100644 --- a/runner/src/index.ts +++ b/runner/src/index.ts @@ -20,6 +20,7 @@ export { } from "./run-plan.ts"; export { FileDurableStateStore, + setDurableStateStoreFSTestHooksForTesting, InvalidApprovalWaitError, PlanIdentityMismatchError, DurableReplayError, @@ -47,17 +48,23 @@ export { type ExecutionOutcome, type ExecutorAdapter, ExecutorAdapterRegistry, + MinimalGateExecutorAdapter, } from "./executor-adapter.ts"; export { type RunnerBrokerClient, type BrokerAcknowledge, - NoopRunnerBrokerClient, + StdioRunnerBrokerClient, + RunnerBrokerTransportError, + MissingRunnerBrokerTransportError, + createSupportedRunnerBrokerClient, } from "./broker-client.ts"; export { RUNNER_CHECKPOINT_REPORT_SCHEMA_ID, RUNNER_RESULT_REPORT_SCHEMA_ID, RUNNER_CHECKPOINT_REPORT_REQUEST_SCHEMA_ID, RUNNER_RESULT_REPORT_REQUEST_SCHEMA_ID, + RUNNER_CHECKPOINT_REPORT_RESPONSE_SCHEMA_ID, + RUNNER_RESULT_REPORT_RESPONSE_SCHEMA_ID, DEPENDENCY_CACHE_HANDOFF_REQUEST_SCHEMA_ID, DEPENDENCY_CACHE_HANDOFF_RESPONSE_SCHEMA_ID, DEPENDENCY_CACHE_HANDOFF_METADATA_SCHEMA_ID, @@ -67,6 +74,8 @@ export { type RunnerResultReport, type RunnerCheckpointReportRequest, type RunnerResultReportRequest, + type RunnerCheckpointReportResponse, + type RunnerResultReportResponse, type DependencyCacheHandoffRequest, type DependencyCacheHandoffMetadata, type DependencyCacheHandoffResponse, @@ -88,7 +97,9 @@ export { RunnerKernel, type ApprovalWaitResolution, type ApprovalWaitResolver, + type EntryExecutionRecord, type KernelExecutionContext, type KernelExecutionModule, + type RunPlanExecutionResult, type RunnerKernelOptions, } from "./kernel.ts"; diff --git a/runner/src/kernel.ts b/runner/src/kernel.ts index 490311d0..80961d62 100644 --- a/runner/src/kernel.ts +++ b/runner/src/kernel.ts @@ -5,6 +5,7 @@ * plan-bound scheduled work with no local planning/authorization semantics. */ +import { createHash } from "node:crypto"; import { InvalidApprovalWaitError, PlanIdentityMismatchError, @@ -14,7 +15,7 @@ import { import { PlanScheduler, type ScheduledWorkItem } from "./scheduler.ts"; import type { DependencyCacheHandoffRequirement, RunnerPlan, RunnerPlanEntry, RunPlanLoader } from "./run-plan.ts"; import { DurableRuntimeSeam, type RunnerRuntimeSeam } from "./runtime-seam.ts"; -import { NoopRunnerBrokerClient, type RunnerBrokerClient } from "./broker-client.ts"; +import { MissingRunnerBrokerTransportError, type RunnerBrokerClient } from "./broker-client.ts"; import type { DependencyCacheHandoffMetadata, PlanBoundExecutionIdentity, @@ -27,6 +28,9 @@ import { RUNNER_CONTRACT_SCHEMA_VERSION, RUNNER_RESULT_REPORT_SCHEMA_ID, } from "./contracts.ts"; +import { MinimalGateExecutorAdapter, type ExecutionOutcome, type ExecutorAdapterRegistry } from "./executor-adapter.ts"; +import { ReportEmitter } from "./report-emitter.ts"; +import { boundedAttemptID, boundedReportRequestID } from "./runner-identifiers.ts"; export type RunnerKernelOptions = { planLoader: RunPlanLoader; @@ -35,6 +39,22 @@ export type RunnerKernelOptions = { runtimeSeam?: RunnerRuntimeSeam; approvalWaitResolver?: ApprovalWaitResolver; brokerClient?: RunnerBrokerClient; + executorAdapterRegistry?: ExecutorAdapterRegistry; +}; + +export type EntryExecutionRecord = { + entry_id: string; + request_ids: { + checkpoint: string; + result: string; + }; + outcome: ExecutionOutcome; +}; + +export type RunPlanExecutionResult = { + plan: RunnerPlan; + work: ScheduledWorkItem[]; + executed: EntryExecutionRecord[]; }; export type ApprovalWaitResolution = { @@ -78,12 +98,21 @@ export class RunnerKernel { private readonly brokerClient: RunnerBrokerClient; + private readonly reportEmitter: ReportEmitter; + + private readonly executorAdapterRegistry: ExecutorAdapterRegistry | undefined; + constructor(options: RunnerKernelOptions) { this.options = options; this.scheduler = options.scheduler ?? new PlanScheduler(); this.runtimeSeam = options.runtimeSeam ?? new DurableRuntimeSeam(options.durableStateStore); this.approvalWaitResolver = options.approvalWaitResolver; - this.brokerClient = options.brokerClient ?? new NoopRunnerBrokerClient(); + if (!options.brokerClient) { + throw new MissingRunnerBrokerTransportError(); + } + this.brokerClient = options.brokerClient; + this.reportEmitter = new ReportEmitter(this.brokerClient); + this.executorAdapterRegistry = options.executorAdapterRegistry; } async initializeFromPlanFile(planFilePath: string): Promise<{ plan: RunnerPlan; work: ScheduledWorkItem[] }> { @@ -94,6 +123,22 @@ export class RunnerKernel { return { plan, work }; } + async executeScheduledWorkFromPlanFile(planFilePath: string): Promise { + const initialized = await this.initializeFromPlanFile(planFilePath); + if (initialized.work.length === 0) { + throw new Error(`RunPlan ${initialized.plan.run_id}/${initialized.plan.plan_id} produced no scheduled work`); + } + const executed: EntryExecutionRecord[] = []; + for (const item of initialized.work) { + executed.push(await this.executeScheduledEntry(initialized.plan, item)); + } + return { + plan: initialized.plan, + work: initialized.work, + executed, + }; + } + async resumeApprovalWaits(): Promise<{ pending_waits: DurableApprovalWait[]; cleared_waits: ClearedApprovalWait[] }> { if (!this.approvalWaitResolver) { throw new Error("approval wait resolver is not configured"); @@ -159,6 +204,75 @@ export class RunnerKernel { return this.composeModules(identity, modules, entry.dependency_cache_handoffs); } + async executeScheduledEntry(plan: RunnerPlan, item: ScheduledWorkItem): Promise { + const identity = this.executionIdentityForEntry(plan, item.entry); + const dependencyCacheHandoffs = await this.resolveDependencyCacheHandoffs(identity, item.entry.dependency_cache_handoffs ?? []); + const adapter = this.resolveExecutorAdapter(item.entry.entry_kind); + const checkpointRequestID = this.reportRequestID("checkpoint", identity, item.entry, item.index); + const resultRequestID = this.reportRequestID("result", identity, item.entry, item.index); + + await this.assertBrokerAccepted(await this.reportEmitter.emitCheckpointReport({ + request_id: checkpointRequestID, + identity, + report: { + lifecycle_state: "active", + checkpoint_code: "gate_started", + occurred_at: new Date().toISOString(), + idempotency_key: `runner-checkpoint:${plan.run_id}:${item.entry.entry_id}:active`, + plan_checkpoint_code: item.entry.checkpoint_code, + plan_order_index: item.entry.order_index, + gate_id: optionalGateString(item.entry.gate.gate_id), + gate_kind: gateKind(item.entry.gate.gate_kind), + gate_version: optionalGateString(item.entry.gate.gate_version), + gate_lifecycle_state: "running", + normalized_input_digests: normalizedInputDigests(item.entry.gate.normalized_inputs), + details: { + entry_id: item.entry.entry_id, + executor_binding_id: item.entry.executor_binding_id, + dependency_cache_handoff_count: dependencyCacheHandoffs.length, + }, + }, + })); + + const outcome = await adapter.execute({ + identity, + entry: item.entry, + dependency_cache_handoffs: dependencyCacheHandoffs, + }); + + await this.assertBrokerAccepted(await this.reportEmitter.emitResultReport({ + request_id: resultRequestID, + identity, + report: { + lifecycle_state: outcome.status === "ok" ? "completed" : "failed", + result_code: outcome.status === "ok" ? "gate_passed" : "gate_failed", + occurred_at: new Date().toISOString(), + idempotency_key: `runner-result:${plan.run_id}:${item.entry.entry_id}:${outcome.status}`, + plan_checkpoint_code: item.entry.checkpoint_code, + plan_order_index: item.entry.order_index, + gate_id: optionalGateString(item.entry.gate.gate_id), + gate_kind: gateKind(item.entry.gate.gate_kind), + gate_version: optionalGateString(item.entry.gate.gate_version), + gate_lifecycle_state: outcome.status === "ok" ? "passed" : "failed", + normalized_input_digests: normalizedInputDigests(item.entry.gate.normalized_inputs), + failure_reason_code: outcome.failure_reason_code, + details: { + entry_id: item.entry.entry_id, + ...outcome.details, + }, + }, + })); + + return { + entry_id: item.entry.entry_id, + request_ids: { + checkpoint: checkpointRequestID, + result: resultRequestID, + }, + outcome, + }; + } + private async resolveDependencyCacheHandoffs( identity: PlanBoundExecutionIdentity, requirements: DependencyCacheHandoffRequirement[], @@ -180,9 +294,48 @@ export class RunnerKernel { return resolved; } + private resolveExecutorAdapter(entryKind: string) { + const adapter = this.executorAdapterRegistry?.resolve(entryKind); + if (adapter) { + return adapter; + } + if (entryKind === "gate") { + return new MinimalGateExecutorAdapter(); + } + throw new Error(`no executor adapter registered for entry kind ${entryKind}`); + } + + private async assertBrokerAccepted(ack: { accepted: boolean; reason?: string }): Promise { + if (!ack.accepted) { + throw new Error(ack.reason ?? "broker rejected runner report"); + } + } + + private executionIdentityForEntry(plan: RunnerPlan, entry: RunnerPlanEntry): PlanBoundExecutionIdentity { + const gateScopeID = typeof entry.gate_id === "string" && entry.gate_id ? entry.gate_id : entry.entry_id; + return { + run_id: plan.run_id, + plan_id: plan.plan_id, + stage_id: entry.stage_id, + step_id: entry.step_id, + role_instance_id: entry.role_instance_id, + stage_attempt_id: boundedAttemptID("stage_attempt", plan.plan_id, entry.stage_id, 1), + step_attempt_id: boundedAttemptID("step_attempt", plan.plan_id, entry.step_id, 1), + gate_attempt_id: boundedAttemptID("gate_attempt", plan.plan_id, gateScopeID, 1), + }; + } + + private reportRequestID(kind: "checkpoint" | "result", identity: PlanBoundExecutionIdentity, entry: RunnerPlanEntry, index: number): string { + return boundedReportRequestID(kind, identity.run_id, entry, index); + } + private dependencyCacheHandoffRequestID(identity: PlanBoundExecutionIdentity, requirement: DependencyCacheHandoffRequirement): string { - const digestSuffix = requirement.request_digest.slice(-12); - return `dependency-handoff:${identity.run_id.slice(0, 24)}:${digestSuffix}`; + const binding = createHash("sha256") + .update(identity.run_id) + .update("\n") + .update(requirement.request_digest) + .digest("hex"); + return `dependency-handoff:${binding}`; } private digestObject(digestIdentity: string): { hash_alg: "sha256"; hash: string } { @@ -260,3 +413,36 @@ export class RunnerKernel { } } + +function optionalGateString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function assertDigestIdentity(value: string, location: string): string { + if (!/^sha256:[a-f0-9]{64}$/.test(value)) { + throw new Error(`${location} must be sha256:`); + } + return value; +} + +function gateKind(value: unknown): RunnerCheckpointReport["gate_kind"] | RunnerResultReport["gate_kind"] | undefined { + return value === "build" || value === "test" || value === "lint" || value === "format" || value === "secret_scan" || value === "policy" + ? value + : undefined; +} + +function normalizedInputDigests(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const digests = value + .map((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return undefined; + } + const digest = (entry as Record).input_digest; + return typeof digest === "string" ? assertDigestIdentity(digest, "gate normalized input digest") : undefined; + }) + .filter((entry): entry is string => Boolean(entry)); + return digests.length > 0 ? digests : undefined; +} diff --git a/runner/src/protocol-schema-bundle.ts b/runner/src/protocol-schema-bundle.ts index aaf6cf54..a2c19402 100644 --- a/runner/src/protocol-schema-bundle.ts +++ b/runner/src/protocol-schema-bundle.ts @@ -22,6 +22,11 @@ type SchemaManifest = { schema_files: SchemaManifestEntry[]; }; +type JsonSchemaLike = JsonObject & { + $id?: unknown; + properties?: Record; +}; + export type SchemaValidationResult = | { ok: true } | { ok: false; reason: string }; @@ -51,6 +56,7 @@ export class ProtocolSchemaBundle { for (const entry of manifest.schema_files) { const schemaPath = path.join(protocolSchemasRoot, entry.path); const schema = await readJsonFile(schemaPath); + assertSchemaManifestEntryMatchesLoadedSchema(entry, schema); ajv.addSchema(schema); schemaPathByRuntimeKey.set(schemaKey(entry.schema_id, entry.schema_version), entry.path); } @@ -76,6 +82,39 @@ export class ProtocolSchemaBundle { return { ok: false, reason: JSON.stringify(validate.errors ?? []) }; } + + hasRuntimeKey(schemaId: string, schemaVersion: string): boolean { + return this.schemaPathByRuntimeKey.has(schemaKey(schemaId, schemaVersion)); + } +} + +function assertSchemaManifestEntryMatchesLoadedSchema(entry: SchemaManifestEntry, schema: JsonObject): void { + const loaded = schema as JsonSchemaLike; + const expectedSchemaPath = `https://runecode.dev/protocol/schemas/${entry.path}`; + if (loaded.$id !== expectedSchemaPath) { + throw new Error(`protocol schema manifest entry ${entry.path} has unexpected $id ${String(loaded.$id ?? "")}`); + } + const schemaID = schemaPropertyConst(loaded, "schema_id"); + if (schemaID !== entry.schema_id) { + throw new Error(`protocol schema manifest entry ${entry.path} schema_id const ${schemaID ?? ""} does not match ${entry.schema_id}`); + } + const schemaVersion = schemaPropertyConst(loaded, "schema_version"); + if (schemaVersion !== entry.schema_version) { + throw new Error(`protocol schema manifest entry ${entry.path} schema_version const ${schemaVersion ?? ""} does not match ${entry.schema_version}`); + } +} + +function schemaPropertyConst(schema: JsonSchemaLike, key: string): string | null { + const properties = schema.properties; + if (!properties || typeof properties !== "object") { + return null; + } + const property = properties[key]; + if (!property || typeof property !== "object") { + return null; + } + const value = (property as Record).const; + return typeof value === "string" ? value : null; } async function readJsonFile(filePath: string): Promise { diff --git a/runner/src/runner-identifiers.ts b/runner/src/runner-identifiers.ts new file mode 100644 index 00000000..adfa0427 --- /dev/null +++ b/runner/src/runner-identifiers.ts @@ -0,0 +1,101 @@ +/** + * Bounded deterministic runner-local identifiers. + * + * These helpers keep runner-generated attempt and request identifiers within + * protocol schema limits while preserving stable plan-scoped derivation. + */ + +import { createHash } from "node:crypto"; +import type { RunnerPlanEntry } from "./run-plan.ts"; + +const MAX_IDENTIFIER_LENGTH = 128; + +export function boundedAttemptID(prefix: string, planID: string, scopeID: string, attemptIndex: number): string { + const digest = createHash("sha256") + .update(planID) + .update("\n") + .update(scopeID) + .digest("hex"); + const token = idToken(scopeID); + const suffix = `${digest}_${attemptIndex}`; + const maxTokenLength = MAX_IDENTIFIER_LENGTH - prefix.length - suffix.length - 2; + const boundedToken = boundedIdentifierToken(token, maxTokenLength); + return `${prefix}_${boundedToken}_${suffix}`; +} + +function boundedIdentifierToken(token: string, maxTokenLength: number): string { + if (maxTokenLength <= 0) { + return "scope"; + } + if (token.length <= maxTokenLength) { + return token; + } + return token.slice(0, maxTokenLength); +} + +export function boundedReportRequestID(kind: "checkpoint" | "result", runID: string, entry: RunnerPlanEntry, index: number): string { + const digest = createHash("sha256") + .update(kind) + .update("\n") + .update(runID) + .update("\n") + .update(entry.entry_id) + .update("\n") + .update(String(index)) + .digest("hex"); + return `runner-${kind}:${digest}`; +} + +function idToken(value: string): string { + const trimmed = value.trim().toLowerCase(); + if (!trimmed) { + return "scope"; + } + let normalized = ""; + for (const character of trimmed) { + normalized += isIdentifierTokenCharacter(character) ? character : "_"; + } + normalized = trimIdentifierTokenSeparators(normalized); + if (!normalized) { + return "scope"; + } + return startsWithASCIILowercase(normalized) ? normalized : `s_${normalized}`; +} + +function isIdentifierTokenCharacter(character: string): boolean { + return startsWithASCIILowercase(character) || isASCIIDigit(character) || character === "_" || character === "-"; +} + +function trimIdentifierTokenSeparators(value: string): string { + let start = 0; + let end = value.length; + + while (start < end && isIdentifierSeparator(value.charAt(start))) { + start += 1; + } + while (end > start && isIdentifierSeparator(value.charAt(end - 1))) { + end -= 1; + } + + return start === 0 && end === value.length ? value : value.slice(start, end); +} + +function startsWithASCIILowercase(value: string): boolean { + if (value.length === 0) { + return false; + } + const code = value.charCodeAt(0); + return code >= 97 && code <= 122; +} + +function isASCIIDigit(value: string): boolean { + if (value.length === 0) { + return false; + } + const code = value.charCodeAt(0); + return code >= 48 && code <= 57; +} + +function isIdentifierSeparator(character: string): boolean { + return character === "_" || character === "-"; +} diff --git a/tools/brokerperf/main.go b/tools/brokerperf/main.go new file mode 100644 index 00000000..92d6375f --- /dev/null +++ b/tools/brokerperf/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "strings" + + "github.com/runecode-ai/runecode/internal/brokerperf" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + var usageErr usageError + if errors.As(err, &usageErr) { + fmt.Fprintf(os.Stderr, "brokerperf usage error: %v\n", err) + os.Exit(2) + } + fmt.Fprintf(os.Stderr, "brokerperf failed: %v\n", err) + os.Exit(1) + } +} + +func run(args []string) error { + fs := flag.NewFlagSet("brokerperf", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + output := fs.String("output", "", "output check json path") + trials := fs.Int("trials", 30, "number of deterministic local trials") + repositoryRoot := fs.String("repository-root", "", "repository root for broker service") + if err := fs.Parse(args); err != nil { + return usageError{err: err} + } + if strings.TrimSpace(*output) == "" { + return usageError{err: fmt.Errorf("--output is required")} + } + out, err := brokerperf.Run(brokerperf.HarnessConfig{Trials: *trials, RepositoryRoot: strings.TrimSpace(*repositoryRoot)}) + if err != nil { + return err + } + raw, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + return os.WriteFile(strings.TrimSpace(*output), raw, 0o644) +} + +type usageError struct{ err error } + +func (e usageError) Error() string { return e.err.Error() } + +func (e usageError) Unwrap() error { return e.err } diff --git a/tools/brokerperf/main_test.go b/tools/brokerperf/main_test.go new file mode 100644 index 00000000..1e2b615f --- /dev/null +++ b/tools/brokerperf/main_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "errors" + "testing" +) + +func TestRunReturnsUsageErrorWhenOutputMissing(t *testing.T) { + err := run([]string{}) + if err == nil { + t.Fatal("run error = nil, want usage error") + } + var usageErr usageError + if !errors.As(err, &usageErr) { + t.Fatalf("run error = %T, want usageError", err) + } +} + +func TestRunReturnsUsageErrorForInvalidFlag(t *testing.T) { + err := run([]string{"--bad-flag"}) + if err == nil { + t.Fatal("run error = nil, want usage error") + } + var usageErr usageError + if !errors.As(err, &usageErr) { + t.Fatalf("run error = %T, want usageError", err) + } +} diff --git a/tools/launcherperf/main.go b/tools/launcherperf/main.go new file mode 100644 index 00000000..90eb5d47 --- /dev/null +++ b/tools/launcherperf/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "strings" + + "github.com/runecode-ai/runecode/internal/launcherperf" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + var usageErr usageError + if errors.As(err, &usageErr) { + fmt.Fprintf(os.Stderr, "launcherperf usage error: %v\n", err) + os.Exit(2) + } + fmt.Fprintf(os.Stderr, "launcherperf failed: %v\n", err) + os.Exit(1) + } +} + +func run(args []string) error { + fs := flag.NewFlagSet("launcherperf", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + output := fs.String("output", "", "output check json path") + if err := fs.Parse(args); err != nil { + return usageError{err: err} + } + if strings.TrimSpace(*output) == "" { + return usageError{err: fmt.Errorf("--output is required")} + } + out, err := launcherperf.Run(launcherperf.HarnessConfig{}) + if err != nil { + return err + } + raw, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + return os.WriteFile(strings.TrimSpace(*output), raw, 0o644) +} + +type usageError struct{ err error } + +func (e usageError) Error() string { return e.err.Error() } + +func (e usageError) Unwrap() error { return e.err } diff --git a/tools/launcherperf/main_test.go b/tools/launcherperf/main_test.go new file mode 100644 index 00000000..1e2b615f --- /dev/null +++ b/tools/launcherperf/main_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "errors" + "testing" +) + +func TestRunReturnsUsageErrorWhenOutputMissing(t *testing.T) { + err := run([]string{}) + if err == nil { + t.Fatal("run error = nil, want usage error") + } + var usageErr usageError + if !errors.As(err, &usageErr) { + t.Fatalf("run error = %T, want usageError", err) + } +} + +func TestRunReturnsUsageErrorForInvalidFlag(t *testing.T) { + err := run([]string{"--bad-flag"}) + if err == nil { + t.Fatal("run error = nil, want usage error") + } + var usageErr usageError + if !errors.As(err, &usageErr) { + t.Fatalf("run error = %T, want usageError", err) + } +} diff --git a/tools/perfcontracts/README.md b/tools/perfcontracts/README.md new file mode 100644 index 00000000..8f1cb191 --- /dev/null +++ b/tools/perfcontracts/README.md @@ -0,0 +1,100 @@ +# Performance Contracts + +This directory under `tools/perfcontracts/` is the reviewed, machine-readable contract surface for performance verification. + +It is intentionally outside `runecontext/` and separate from `runecontext/assurance/baseline.yaml`. + +## Artifact Format + +- `manifest.json` is the authoritative inventory for checked-in performance contracts and optional repeated-sample baselines. +- `fixtures/*.json` contains reviewed fixture inventory and stable fixture IDs. +- `contracts/*.json` contains per-surface metric contracts. +- `baselines/*.json` contains optional repeated-sample baseline artifacts for regression and hybrid budgets. + +Each metric contract declares: + +- metric identity and fixture identity +- measurement kind and unit +- measurement profile for one shared architecture across reviewed deployment scales +- budget class (`exact`, `absolute-budget`, `regression-budget`, `hybrid-budget`) +- lane authority and activation state +- threshold origin +- timing boundary (`start_event`, `end_event`, `clock_source`, `evidence_source`, `included_phases`) + +## Baseline Governance + +## Measurement profiles + +Measurement profiles describe the reviewed hardware/deployment lane used to collect a metric without creating separate product architectures. + +- `linux_shared_ci` is the authoritative required gate profile for shared Linux CI/local parity. +- `linux_pi_reference` records the same architecture on Raspberry Pi-class reference hardware. +- `linux_scaled_reference` records the same architecture on scaled Linux reference hardware. + +Profiles are descriptive contract metadata. Required beta enforcement continues to flow through lane authority and activation state rather than splitting the product into separate perf paths. + +### Threshold review process + +Threshold changes are contract changes, not harness-only edits. + +- Tightening a threshold requires explicit rationale, evidence, and expected operator or product impact. +- Deliberate threshold loosening (accepted regression) requires explicit justification in review notes and must explain why the regression is acceptable now. +- Every threshold keeps a reviewed `threshold_origin` (`product_budget`, `investigation_baseline`, `first_calibration`, `temporary_guardrail`) so provenance remains inspectable. +- `threshold_origin` values are validated by `internal/perfcontracts` and must not be free-form. + +### Baseline refresh policy for major architecture shifts + +When a major reviewed architectural shift lands, refresh baselines with an explicit review path: + +1. Keep metric identity stable (`metric_id`, fixture, timing boundary, budget class) unless semantics truly changed. +2. If semantics changed, add a new metric or fixture identity rather than silently reusing old identities. +3. Collect repeated samples using the reviewed defaults below in the authoritative environment. +4. Commit refreshed baseline artifacts and any threshold changes together with rationale. +5. Keep normal CI check-only; baseline refresh is intentional and reviewed, never auto-mutated. + +## Statistical defaults (reviewed v1) + +These defaults are the initial contract constants for CHG-053 and are tuned only through explicit follow-up review. + +- **Microbenchmarks** + - repeated samples: `10` for required PR comparisons + - repeated samples: `20` preferred for baseline refresh or recalibration + - comparison: robust repeated-sample regression check with practical noise-floor gate +- **Latency metrics** + - trials: `30` when `p95` is authoritative + - p95 eligibility: require fixed repeated trials sufficient for meaningful p95; otherwise use median+max while informational + - comparison: explicit reviewed ceilings (`p95` or median+max per metric contract) +- **CPU/process-behavior metrics** + - warmup window: `3000ms` + - observation window: `20000ms` + - repeated windows: `3` + - comparison: sustained average/median signal plus max guardrail +- **Exact metrics** + - comparison: exact value or hard bound only (no inferential statistics) + +### Practical noise-floor policy + +Regression checks that use repeated-sample comparisons must require both: + +- regression threshold exceeded (for example, `max_regression_percent`) +- practical noise floor exceeded (`practical_noise_floor`) + +This avoids gate churn from statistically detectable but operationally irrelevant movement. + +## CI Contract + +The trusted compare/enforce tool is `go run ./tools/perfcontracts`. + +Normal CI runs are check-only: + +- contract validation and compare are read-only +- no baseline rewrite behavior is allowed in verification flows + +## Scope + +This initial inventory intentionally covers one reviewed MVP fixture set per major surface. +Broader fixture ladders and cross-platform expansion are deferred to: + +- `CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0` + +Deferred broader performance surfaces include larger fixture ladders, wider workflow-pack and git-gateway coverage, and tuned macOS/Windows numeric-gate programs. diff --git a/tools/perfcontracts/baselines/metric.audit.finalize_verify.wall_ms.v1.json b/tools/perfcontracts/baselines/metric.audit.finalize_verify.wall_ms.v1.json new file mode 100644 index 00000000..9310b59a --- /dev/null +++ b/tools/perfcontracts/baselines/metric.audit.finalize_verify.wall_ms.v1.json @@ -0,0 +1,9 @@ +{ + "schema_version":"runecode.performance.baseline.v1", + "metric_id": "metric.audit.finalize_verify.wall_ms", + "unit": "ms", + "samples": [894.885], + "summary": { + "median": 894.885 + } +} diff --git a/tools/perfcontracts/baselines/metric.audit.verify_current_segment.wall_ms.v1.json b/tools/perfcontracts/baselines/metric.audit.verify_current_segment.wall_ms.v1.json new file mode 100644 index 00000000..8989589a --- /dev/null +++ b/tools/perfcontracts/baselines/metric.audit.verify_current_segment.wall_ms.v1.json @@ -0,0 +1,9 @@ +{ + "schema_version":"runecode.performance.baseline.v1", + "metric_id": "metric.audit.verify_current_segment.wall_ms", + "unit": "ms", + "samples": [234.0275], + "summary": { + "median": 234.0275 + } +} diff --git a/tools/perfcontracts/baselines/metric.broker.unary.session_list.p95_ms.v1.json b/tools/perfcontracts/baselines/metric.broker.unary.session_list.p95_ms.v1.json new file mode 100644 index 00000000..ba8638dd --- /dev/null +++ b/tools/perfcontracts/baselines/metric.broker.unary.session_list.p95_ms.v1.json @@ -0,0 +1,20 @@ +{ + "schema_version":"runecode.performance.baseline.v1", + "metric_id": "metric.broker.unary.session_list.p95_ms", + "unit": "ms", + "samples": [ + 47, + 51, + 49, + 52, + 50, + 48, + 53, + 50, + 49, + 51 + ], + "summary": { + "median": 50 + } +} diff --git a/tools/perfcontracts/baselines/metric.deps.cache_hit.small.wall_ms.v1.json b/tools/perfcontracts/baselines/metric.deps.cache_hit.small.wall_ms.v1.json new file mode 100644 index 00000000..4c241e62 --- /dev/null +++ b/tools/perfcontracts/baselines/metric.deps.cache_hit.small.wall_ms.v1.json @@ -0,0 +1,9 @@ +{ + "schema_version":"runecode.performance.baseline.v1", + "metric_id": "metric.deps.cache_hit.small.wall_ms", + "unit": "ms", + "samples": [34, 36, 35, 35, 37], + "summary": { + "median": 35 + } +} diff --git a/tools/perfcontracts/baselines/metric.deps.cache_miss.small.wall_ms.v1.json b/tools/perfcontracts/baselines/metric.deps.cache_miss.small.wall_ms.v1.json new file mode 100644 index 00000000..9953921b --- /dev/null +++ b/tools/perfcontracts/baselines/metric.deps.cache_miss.small.wall_ms.v1.json @@ -0,0 +1,9 @@ +{ + "schema_version":"runecode.performance.baseline.v1", + "metric_id": "metric.deps.cache_miss.small.wall_ms", + "unit": "ms", + "samples": [338, 345, 342, 340, 344], + "summary": { + "median": 342 + } +} diff --git a/tools/perfcontracts/baselines/metric.deps.materialization.workspace_handoff.wall_ms.v1.json b/tools/perfcontracts/baselines/metric.deps.materialization.workspace_handoff.wall_ms.v1.json new file mode 100644 index 00000000..be370e6f --- /dev/null +++ b/tools/perfcontracts/baselines/metric.deps.materialization.workspace_handoff.wall_ms.v1.json @@ -0,0 +1,9 @@ +{ + "schema_version":"runecode.performance.baseline.v1", + "metric_id": "metric.deps.materialization.workspace_handoff.wall_ms", + "unit": "ms", + "samples": [58, 61, 60, 59, 62], + "summary": { + "median": 60 + } +} diff --git a/tools/perfcontracts/baselines/metric.runner.boundary_check.wall_ms.v1.json b/tools/perfcontracts/baselines/metric.runner.boundary_check.wall_ms.v1.json new file mode 100644 index 00000000..4f1efbf8 --- /dev/null +++ b/tools/perfcontracts/baselines/metric.runner.boundary_check.wall_ms.v1.json @@ -0,0 +1,9 @@ +{ + "schema_version":"runecode.performance.baseline.v1", + "metric_id": "metric.runner.boundary_check.wall_ms", + "unit": "ms", + "samples": [980, 1005, 995, 1010, 990], + "summary": { + "median": 995 + } +} diff --git a/tools/perfcontracts/baselines/metric.runner.protocol_fixtures.wall_ms.v1.json b/tools/perfcontracts/baselines/metric.runner.protocol_fixtures.wall_ms.v1.json new file mode 100644 index 00000000..1ffd0e2e --- /dev/null +++ b/tools/perfcontracts/baselines/metric.runner.protocol_fixtures.wall_ms.v1.json @@ -0,0 +1,9 @@ +{ + "schema_version":"runecode.performance.baseline.v1", + "metric_id": "metric.runner.protocol_fixtures.wall_ms", + "unit": "ms", + "samples": [2650, 2725, 2685, 2710, 2690], + "summary": { + "median": 2690 + } +} diff --git a/tools/perfcontracts/baselines/metric.tui.render.shell_view_waiting.ns_op.v1.json b/tools/perfcontracts/baselines/metric.tui.render.shell_view_waiting.ns_op.v1.json new file mode 100644 index 00000000..26081eb8 --- /dev/null +++ b/tools/perfcontracts/baselines/metric.tui.render.shell_view_waiting.ns_op.v1.json @@ -0,0 +1,11 @@ +{ + "schema_version":"runecode.performance.baseline.v1", + "metric_id": "metric.tui.render.shell_view_waiting.ns_op", + "unit": "ns/op", + "samples": [ + 2503093.5 + ], + "summary": { + "median": 2503093.5 + } +} diff --git a/tools/perfcontracts/baselines/metric.workflow.chg049.first_party_beta_slice.wall_ms.v1.json b/tools/perfcontracts/baselines/metric.workflow.chg049.first_party_beta_slice.wall_ms.v1.json new file mode 100644 index 00000000..27507897 --- /dev/null +++ b/tools/perfcontracts/baselines/metric.workflow.chg049.first_party_beta_slice.wall_ms.v1.json @@ -0,0 +1,9 @@ +{ + "schema_version":"runecode.performance.baseline.v1", + "metric_id": "metric.workflow.chg049.first_party_beta_slice.wall_ms", + "unit": "ms", + "samples": [420, 432, 428, 425, 430], + "summary": { + "median": 428 + } +} diff --git a/tools/perfcontracts/baselines/metric.workflow.chg050.compile.wall_ms.v1.json b/tools/perfcontracts/baselines/metric.workflow.chg050.compile.wall_ms.v1.json new file mode 100644 index 00000000..94dc77d5 --- /dev/null +++ b/tools/perfcontracts/baselines/metric.workflow.chg050.compile.wall_ms.v1.json @@ -0,0 +1,9 @@ +{ + "schema_version":"runecode.performance.baseline.v1", + "metric_id": "metric.workflow.chg050.compile.wall_ms", + "unit": "ms", + "samples": [278, 284, 281, 286, 280], + "summary": { + "median": 281 + } +} diff --git a/tools/perfcontracts/baselines/metric.workflow.chg050.runplan_persist_load.wall_ms.v1.json b/tools/perfcontracts/baselines/metric.workflow.chg050.runplan_persist_load.wall_ms.v1.json new file mode 100644 index 00000000..c294be4d --- /dev/null +++ b/tools/perfcontracts/baselines/metric.workflow.chg050.runplan_persist_load.wall_ms.v1.json @@ -0,0 +1,9 @@ +{ + "schema_version":"runecode.performance.baseline.v1", + "metric_id": "metric.workflow.chg050.runplan_persist_load.wall_ms", + "unit": "ms", + "samples": [68, 71, 69, 70, 72], + "summary": { + "median": 70 + } +} diff --git a/tools/perfcontracts/baselines/metric.workflow.chg050.validation_canonicalization.wall_ms.v1.json b/tools/perfcontracts/baselines/metric.workflow.chg050.validation_canonicalization.wall_ms.v1.json new file mode 100644 index 00000000..427fe0b4 --- /dev/null +++ b/tools/perfcontracts/baselines/metric.workflow.chg050.validation_canonicalization.wall_ms.v1.json @@ -0,0 +1,9 @@ +{ + "schema_version":"runecode.performance.baseline.v1", + "metric_id": "metric.workflow.chg050.validation_canonicalization.wall_ms", + "unit": "ms", + "samples": [92, 96, 94, 95, 93], + "summary": { + "median": 94 + } +} diff --git a/tools/perfcontracts/contracts/attestation.v1.json b/tools/perfcontracts/contracts/attestation.v1.json new file mode 100644 index 00000000..fa438342 --- /dev/null +++ b/tools/perfcontracts/contracts/attestation.v1.json @@ -0,0 +1,57 @@ +{ + "schema_version":"runecode.performance.contract.v1", + "contract_id": "performance.attestation.v1", + "surface": "attestation", + "metrics": [ + { + "metric_id": "metric.attestation.cold.verify.wall_ms", + "subsystem": "attestation", + "runtime_regime": "cold_path", + "fixture_id": "attestation.cold.signed-runtime.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 10, "median_max_authoritative": true}, + "budget_class": "regression-budget", + "threshold": {"max_regression_percent": 15}, + "lane_authority": "contract_pending_dependency", + "activation_state": "contract_pending_dependency", + "comparison_method": "median_regression_with_noise_floor", + "practical_noise_floor": 15, + "threshold_origin": "temporary_guardrail", + "timing_boundary": { + "start_event": "runtime.launch.start", + "end_event": "attestation.verification.persisted", + "clock_source": "monotonic", + "evidence_source": "trusted_runtime_evidence_and_broker_projection", + "included_phases": ["launch", "secure_session_validation", "post_handshake_runtime_evidence", "attestation_verification", "replay_check", "freshness_check", "evidence_persistence", "broker_projection"] + }, + "notes": "Pending CHG-2026-054 truthful post-handshake attestation gate" + }, + { + "metric_id": "metric.attestation.warm.verify.wall_ms", + "subsystem": "attestation", + "runtime_regime": "warm_path", + "fixture_id": "attestation.warm.signed-runtime.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 10, "median_max_authoritative": true}, + "budget_class": "regression-budget", + "threshold": {"max_regression_percent": 15}, + "lane_authority": "contract_pending_dependency", + "activation_state": "contract_pending_dependency", + "comparison_method": "median_regression_with_noise_floor", + "practical_noise_floor": 10, + "threshold_origin": "temporary_guardrail", + "timing_boundary": { + "start_event": "runtime.launch.start", + "end_event": "attestation.verification.persisted", + "clock_source": "monotonic", + "evidence_source": "trusted_runtime_evidence_and_broker_projection", + "included_phases": ["launch", "secure_session_validation", "post_handshake_runtime_evidence", "attestation_verification_cache_hit", "replay_check", "freshness_check", "evidence_persistence", "broker_projection"] + }, + "notes": "Pending CHG-2026-054 truthful post-handshake attestation gate" + } + ] +} diff --git a/tools/perfcontracts/contracts/broker.v1.json b/tools/perfcontracts/contracts/broker.v1.json new file mode 100644 index 00000000..5c045e09 --- /dev/null +++ b/tools/perfcontracts/contracts/broker.v1.json @@ -0,0 +1,608 @@ +{ + "schema_version":"runecode.performance.contract.v1", + "contract_id": "performance.broker.v1", + "surface": "broker", + "metrics": [ + { + "metric_id": "metric.broker.unary.session_list.p95_ms", + "subsystem": "broker", + "runtime_regime": "unary_api", + "fixture_id": "broker.unary.beta-small.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "hybrid-budget", + "threshold": {"max_value": 150, "max_regression_percent": 15}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "baseline_source": "reviewed_repeated_samples", + "baseline_ref": "baselines/metric.broker.unary.session_list.p95_ms.v1.json", + "comparison_method": "p95_ceiling_plus_regression", + "practical_noise_floor": 5, + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "broker.rpc.request.accepted", + "end_event": "broker.rpc.response.serialized", + "clock_source": "monotonic", + "evidence_source": "broker_rpc_events", + "included_phases": ["request_validation", "store_query", "response_projection"] + } + }, + { + "metric_id": "metric.broker.unary.session_get.p95_ms", + "subsystem": "broker", + "runtime_regime": "unary_api", + "fixture_id": "broker.unary.beta-small.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 150}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "broker.rpc.request.accepted", + "end_event": "broker.rpc.response.serialized", + "clock_source": "monotonic", + "evidence_source": "broker_rpc_events", + "included_phases": ["request_validation", "store_query", "response_projection"] + } + }, + { + "metric_id": "metric.broker.unary.run_list.p95_ms", + "subsystem": "broker", + "runtime_regime": "unary_api", + "fixture_id": "broker.unary.beta-small.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 150}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "broker.rpc.request.accepted", + "end_event": "broker.rpc.response.serialized", + "clock_source": "monotonic", + "evidence_source": "broker_rpc_events", + "included_phases": ["request_validation", "store_query", "response_projection"] + } + }, + { + "metric_id": "metric.broker.unary.run_get.p95_ms", + "subsystem": "broker", + "runtime_regime": "unary_api", + "fixture_id": "broker.unary.beta-small.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 150}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "broker.rpc.request.accepted", + "end_event": "broker.rpc.response.serialized", + "clock_source": "monotonic", + "evidence_source": "broker_rpc_events", + "included_phases": ["request_validation", "store_query", "response_projection"] + } + }, + { + "metric_id": "metric.broker.unary.approval_list.p95_ms", + "subsystem": "broker", + "runtime_regime": "unary_api", + "fixture_id": "broker.unary.beta-small.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 150}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "broker.rpc.request.accepted", + "end_event": "broker.rpc.response.serialized", + "clock_source": "monotonic", + "evidence_source": "broker_rpc_events", + "included_phases": ["request_validation", "store_query", "response_projection"] + } + }, + { + "metric_id": "metric.broker.unary.readiness_get.p95_ms", + "subsystem": "broker", + "runtime_regime": "unary_api", + "fixture_id": "broker.unary.beta-small.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 150}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "broker.rpc.request.accepted", + "end_event": "broker.rpc.response.serialized", + "clock_source": "monotonic", + "evidence_source": "broker_rpc_events", + "included_phases": ["request_validation", "readiness_projection", "response_projection"] + } + }, + { + "metric_id": "metric.broker.unary.version_info_get.p95_ms", + "subsystem": "broker", + "runtime_regime": "unary_api", + "fixture_id": "broker.unary.beta-small.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 150}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "broker.rpc.request.accepted", + "end_event": "broker.rpc.response.serialized", + "clock_source": "monotonic", + "evidence_source": "broker_rpc_events", + "included_phases": ["request_validation", "version_projection", "response_projection"] + } + }, + { + "metric_id": "metric.broker.unary.project_substrate_posture_get.p95_ms", + "subsystem": "broker", + "runtime_regime": "unary_api", + "fixture_id": "broker.unary.beta-small.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 150}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "broker.rpc.request.accepted", + "end_event": "broker.rpc.response.serialized", + "clock_source": "monotonic", + "evidence_source": "broker_rpc_events", + "included_phases": ["request_validation", "project_substrate_projection", "response_projection"] + } + }, + { + "metric_id": "metric.broker.watch.run.snapshot_follow.p95_ms", + "subsystem": "broker", + "runtime_regime": "watch_run", + "fixture_id": "broker.watch.run.snapshot-follow.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 200}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "watch.subscribe", + "end_event": "watch.snapshot_follow.received", + "clock_source": "monotonic", + "evidence_source": "broker_watch_events", + "included_phases": ["snapshot", "follow"] + } + }, + { + "metric_id": "metric.broker.watch.approval.snapshot_follow.p95_ms", + "subsystem": "broker", + "runtime_regime": "watch_approval", + "fixture_id": "broker.watch.approval.snapshot-follow.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 200}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "watch.subscribe", + "end_event": "watch.snapshot_follow.received", + "clock_source": "monotonic", + "evidence_source": "broker_watch_events", + "included_phases": ["snapshot", "follow"] + } + }, + { + "metric_id": "metric.broker.watch.session.snapshot_follow.p95_ms", + "subsystem": "broker", + "runtime_regime": "watch_session", + "fixture_id": "broker.watch.session.snapshot-follow.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 200}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "watch.subscribe", + "end_event": "watch.snapshot_follow.received", + "clock_source": "monotonic", + "evidence_source": "broker_watch_events", + "included_phases": ["snapshot", "follow"] + } + }, + { + "metric_id": "metric.broker.watch.turn_execution.snapshot_follow.p95_ms", + "subsystem": "broker", + "runtime_regime": "watch_turn_execution", + "fixture_id": "broker.watch.turn-execution.snapshot-follow.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 200}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "watch.subscribe", + "end_event": "watch.snapshot_follow.received", + "clock_source": "monotonic", + "evidence_source": "broker_watch_events", + "included_phases": ["snapshot", "follow"] + } + }, + { + "metric_id": "metric.broker.watch.run.snapshot_follow.payload_bytes", + "subsystem": "broker", + "runtime_regime": "watch_run", + "fixture_id": "broker.watch.run.snapshot-follow.v1", + "measurement_kind": "payload_growth", + "unit": "bytes", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 100000}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "absolute_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "watch.snapshot.serialized", + "end_event": "watch.terminal.serialized", + "clock_source": "event_counter", + "evidence_source": "watch_payload", + "included_phases": ["snapshot", "follow"] + } + }, + { + "metric_id": "metric.broker.watch.approval.snapshot_follow.payload_bytes", + "subsystem": "broker", + "runtime_regime": "watch_approval", + "fixture_id": "broker.watch.approval.snapshot-follow.v1", + "measurement_kind": "payload_growth", + "unit": "bytes", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 100000}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "absolute_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "watch.snapshot.serialized", + "end_event": "watch.terminal.serialized", + "clock_source": "event_counter", + "evidence_source": "watch_payload", + "included_phases": ["snapshot", "follow"] + } + }, + { + "metric_id": "metric.broker.watch.session.snapshot_follow.payload_bytes", + "subsystem": "broker", + "runtime_regime": "watch_session", + "fixture_id": "broker.watch.session.snapshot-follow.v1", + "measurement_kind": "payload_growth", + "unit": "bytes", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 100000}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "absolute_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "watch.snapshot.serialized", + "end_event": "watch.terminal.serialized", + "clock_source": "event_counter", + "evidence_source": "watch_payload", + "included_phases": ["snapshot", "follow"] + } + }, + { + "metric_id": "metric.broker.watch.turn_execution.snapshot_follow.payload_bytes", + "subsystem": "broker", + "runtime_regime": "watch_turn_execution", + "fixture_id": "broker.watch.turn-execution.snapshot-follow.v1", + "measurement_kind": "payload_growth", + "unit": "bytes", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 100000}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "absolute_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "watch.snapshot.serialized", + "end_event": "watch.terminal.serialized", + "clock_source": "event_counter", + "evidence_source": "watch_payload", + "included_phases": ["snapshot", "follow"] + } + }, + { + "metric_id": "metric.broker.watch.run.snapshot_follow.event_count", + "subsystem": "broker", + "runtime_regime": "watch_run", + "fixture_id": "broker.watch.run.snapshot-follow.v1", + "measurement_kind": "count", + "unit": "count", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {}, + "budget_class": "exact", + "threshold": {"exact_value": 3}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "exact_match", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "watch.snapshot", + "end_event": "watch.terminal", + "clock_source": "event_counter", + "evidence_source": "watch_payload", + "included_phases": ["snapshot", "follow"] + } + }, + { + "metric_id": "metric.broker.watch.approval.snapshot_follow.event_count", + "subsystem": "broker", + "runtime_regime": "watch_approval", + "fixture_id": "broker.watch.approval.snapshot-follow.v1", + "measurement_kind": "count", + "unit": "count", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {}, + "budget_class": "exact", + "threshold": {"exact_value": 3}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "exact_match", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "watch.snapshot", + "end_event": "watch.terminal", + "clock_source": "event_counter", + "evidence_source": "watch_payload", + "included_phases": ["snapshot", "follow"] + } + }, + { + "metric_id": "metric.broker.watch.session.snapshot_follow.event_count", + "subsystem": "broker", + "runtime_regime": "watch_session", + "fixture_id": "broker.watch.session.snapshot-follow.v1", + "measurement_kind": "count", + "unit": "count", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {}, + "budget_class": "exact", + "threshold": {"exact_value": 3}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "exact_match", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "watch.snapshot", + "end_event": "watch.terminal", + "clock_source": "event_counter", + "evidence_source": "watch_payload", + "included_phases": ["snapshot", "follow"] + } + }, + { + "metric_id": "metric.broker.watch.turn_execution.snapshot_follow.event_count", + "subsystem": "broker", + "runtime_regime": "watch_turn_execution", + "fixture_id": "broker.watch.turn-execution.snapshot-follow.v1", + "measurement_kind": "count", + "unit": "count", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {}, + "budget_class": "exact", + "threshold": {"exact_value": 3}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "exact_match", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "watch.snapshot", + "end_event": "watch.terminal", + "clock_source": "event_counter", + "evidence_source": "watch_payload", + "included_phases": ["snapshot", "follow"] + } + }, + { + "metric_id": "metric.broker.mutation.session_execution_trigger.p95_ms", + "subsystem": "broker", + "runtime_regime": "mutation_session_execution_trigger", + "fixture_id": "broker.unary.beta-small.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 200}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "session.execution.trigger.request.accepted", + "end_event": "session.execution.trigger.ack.persisted", + "clock_source": "monotonic", + "evidence_source": "broker_rpc_events", + "included_phases": ["request_validation", "policy_evaluation", "durable_trigger_persist", "ack_projection"] + } + }, + { + "metric_id": "metric.broker.mutation.session_execution_continue.p95_ms", + "subsystem": "broker", + "runtime_regime": "mutation_session_execution_continue", + "fixture_id": "broker.unary.beta-small.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 200}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "session.execution.continue.request.accepted", + "end_event": "session.execution.continue.ack.persisted", + "clock_source": "monotonic", + "evidence_source": "broker_rpc_events", + "included_phases": ["request_validation", "target_resume_selection", "durable_turn_update", "ack_projection"] + } + }, + { + "metric_id": "metric.broker.mutation.approval_resolve.p95_ms", + "subsystem": "broker", + "runtime_regime": "mutation_approval_resolve", + "fixture_id": "broker.unary.beta-small.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 200}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "approval.resolve.request.accepted", + "end_event": "approval.resolve.persisted", + "clock_source": "monotonic", + "evidence_source": "broker_rpc_events", + "included_phases": ["request_validation", "signature_verification", "approval_state_update", "posture_apply_or_publish", "audit_persist"] + } + }, + { + "metric_id": "metric.broker.mutation.backend_posture_change.p95_ms", + "subsystem": "broker", + "runtime_regime": "mutation_backend_posture_change", + "fixture_id": "broker.unary.beta-small.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 200}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "backend.posture.change.request.accepted", + "end_event": "backend.posture.change.outcome.persisted", + "clock_source": "monotonic", + "evidence_source": "broker_rpc_events", + "included_phases": ["request_validation", "policy_evaluation", "approval_gate_or_apply", "outcome_projection"] + } + }, + { + "metric_id": "metric.broker.attach.local_control_plane.p95_ms", + "subsystem": "broker", + "runtime_regime": "attach_local_control_plane", + "fixture_id": "broker.unary.beta-small.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 500}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "product_budget", + "timing_boundary": { + "start_event": "attach.request.accepted", + "end_event": "product.lifecycle.posture.attachable_projected", + "clock_source": "monotonic", + "evidence_source": "broker_product_lifecycle_posture", + "included_phases": ["request_validation", "project_substrate_discovery", "lifecycle_projection"] + } + }, + { + "metric_id": "metric.broker.resume.local_control_plane.p95_ms", + "subsystem": "broker", + "runtime_regime": "resume_local_control_plane", + "fixture_id": "broker.unary.beta-small.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 500}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "product_budget", + "timing_boundary": { + "start_event": "resume.attach.request.accepted", + "end_event": "product.lifecycle.posture.attachable_projected", + "clock_source": "monotonic", + "evidence_source": "broker_product_lifecycle_posture", + "included_phases": ["request_validation", "lifecycle_generation_reconcile", "lifecycle_projection"] + } + } + ] +} diff --git a/tools/perfcontracts/contracts/dependency-audit.v1.json b/tools/perfcontracts/contracts/dependency-audit.v1.json new file mode 100644 index 00000000..c11d423e --- /dev/null +++ b/tools/perfcontracts/contracts/dependency-audit.v1.json @@ -0,0 +1,367 @@ +{ + "schema_version":"runecode.performance.contract.v1", + "contract_id": "performance.dependency-audit.v1", + "surface": "dependency-audit", + "metrics": [ + { + "metric_id": "metric.deps.cache_miss.small.wall_ms", + "subsystem": "dependency", + "runtime_regime": "cache_miss", + "fixture_id": "deps.cache-miss.small.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 10, "median_max_authoritative": true}, + "budget_class": "regression-budget", + "threshold": {"max_regression_percent": 15}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "baseline_source": "reviewed_repeated_samples", + "baseline_ref": "baselines/metric.deps.cache_miss.small.wall_ms.v1.json", + "comparison_method": "median_regression_with_noise_floor", + "practical_noise_floor": 5, + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "dependency.cache.miss.start", + "end_event": "dependency.cache.miss.complete", + "clock_source": "monotonic", + "evidence_source": "broker_dependency_audit", + "included_phases": ["fetch", "cas_write", "manifest_persist"] + } + }, + { + "metric_id": "metric.deps.cache_hit.small.wall_ms", + "subsystem": "dependency", + "runtime_regime": "cache_hit", + "fixture_id": "deps.cache-hit.small.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 10, "median_max_authoritative": true}, + "budget_class": "regression-budget", + "threshold": {"max_regression_percent": 15}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "baseline_source": "reviewed_repeated_samples", + "baseline_ref": "baselines/metric.deps.cache_hit.small.wall_ms.v1.json", + "comparison_method": "median_regression_with_noise_floor", + "practical_noise_floor": 5, + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "dependency.cache.hit.start", + "end_event": "dependency.cache.hit.complete", + "clock_source": "monotonic", + "evidence_source": "broker_dependency_audit", + "included_phases": ["lookup", "response_projection"] + } + }, + { + "metric_id": "metric.deps.cache_coalesced.upstream_fetch_count", + "subsystem": "dependency", + "runtime_regime": "coalesced_miss", + "fixture_id": "deps.coalesced-miss.small.v1", + "measurement_kind": "count", + "unit": "count", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {}, + "budget_class": "exact", + "threshold": {"exact_value": 1}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "exact_match", + "threshold_origin": "product_budget", + "timing_boundary": { + "start_event": "dependency.cache.ensure.start", + "end_event": "dependency.cache.ensure.complete", + "clock_source": "event_counter", + "evidence_source": "broker_dependency_audit", + "included_phases": ["fetch", "cas_write"] + } + }, + { + "metric_id": "metric.deps.cache_coalesced.cas_write_count", + "subsystem": "dependency", + "runtime_regime": "coalesced_miss", + "fixture_id": "deps.coalesced-miss.small.v1", + "measurement_kind": "count", + "unit": "count", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {}, + "budget_class": "exact", + "threshold": {"exact_value": 1}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "exact_match", + "threshold_origin": "product_budget", + "timing_boundary": { + "start_event": "dependency.cache.ensure.start", + "end_event": "dependency.cache.ensure.complete", + "clock_source": "event_counter", + "evidence_source": "broker_dependency_audit", + "included_phases": ["single_flight", "cas_write"] + } + }, + { + "metric_id": "metric.deps.materialization.workspace_handoff.wall_ms", + "subsystem": "dependency", + "runtime_regime": "materialization_workspace_handoff", + "fixture_id": "deps.cache-hit.small.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 10, "median_max_authoritative": true}, + "budget_class": "regression-budget", + "threshold": {"max_regression_percent": 15}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "baseline_source": "reviewed_repeated_samples", + "baseline_ref": "baselines/metric.deps.materialization.workspace_handoff.wall_ms.v1.json", + "comparison_method": "median_regression_with_noise_floor", + "practical_noise_floor": 5, + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "dependency.handoff.request.accepted", + "end_event": "dependency.handoff.metadata.projected", + "clock_source": "monotonic", + "evidence_source": "broker_dependency_audit", + "included_phases": ["request_validation", "cache_lookup", "handoff_projection"] + } + }, + { + "metric_id": "metric.deps.materialization.workspace_handoff.found_count", + "subsystem": "dependency", + "runtime_regime": "materialization_workspace_handoff", + "fixture_id": "deps.cache-hit.small.v1", + "measurement_kind": "count", + "unit": "count", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {}, + "budget_class": "exact", + "threshold": {"exact_value": 1}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "exact_match", + "threshold_origin": "product_budget", + "timing_boundary": { + "start_event": "dependency.handoff.request.accepted", + "end_event": "dependency.handoff.metadata.projected", + "clock_source": "event_counter", + "evidence_source": "broker_dependency_audit", + "included_phases": ["lookup", "projection"] + } + }, + { + "metric_id": "metric.deps.stream_to_cas.max_read_buffer_bytes", + "subsystem": "dependency", + "runtime_regime": "stream_to_cas", + "fixture_id": "deps.cache-miss.small.v1", + "measurement_kind": "count", + "unit": "bytes", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 131072}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "absolute_ceiling", + "threshold_origin": "product_budget", + "timing_boundary": { + "start_event": "dependency.cache.fill.stream.start", + "end_event": "dependency.cache.fill.stream.complete", + "clock_source": "event_counter", + "evidence_source": "bounded_buffer_instrumentation", + "included_phases": ["read_chunk", "stream_to_cas"] + } + }, + { + "metric_id": "metric.deps.stream_to_cas.read_calls", + "subsystem": "dependency", + "runtime_regime": "stream_to_cas", + "fixture_id": "deps.cache-miss.small.v1", + "measurement_kind": "count", + "unit": "count", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 2000}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "absolute_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "dependency.cache.fill.stream.start", + "end_event": "dependency.cache.fill.stream.complete", + "clock_source": "event_counter", + "evidence_source": "bounded_buffer_instrumentation", + "included_phases": ["read_chunk", "stream_to_cas"] + } + }, + { + "metric_id": "metric.deps.stream_to_cas.fetched_bytes", + "subsystem": "dependency", + "runtime_regime": "stream_to_cas", + "fixture_id": "deps.cache-miss.small.v1", + "measurement_kind": "count", + "unit": "bytes", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 4194304}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "absolute_ceiling", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "dependency.cache.fill.stream.start", + "end_event": "dependency.cache.fill.stream.complete", + "clock_source": "event_counter", + "evidence_source": "broker_dependency_audit", + "included_phases": ["stream_to_cas"] + } + }, + { + "metric_id": "metric.deps.cache_fill.peak_alloc_mb", + "subsystem": "dependency", + "runtime_regime": "stream_to_cas_memory_guardrail", + "fixture_id": "deps.cache-miss.small.v1", + "measurement_kind": "memory", + "unit": "mb", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 5, "median_max_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 32}, + "lane_authority": "informational_until_stable", + "activation_state": "informational", + "comparison_method": "max_ceiling", + "threshold_origin": "temporary_guardrail", + "timing_boundary": { + "start_event": "dependency.cache.fill.start", + "end_event": "dependency.cache.fill.complete", + "clock_source": "runtime_memstats", + "evidence_source": "process_memory_observation", + "included_phases": ["stream_to_cas", "manifest_persist"] + } + }, + { + "metric_id": "metric.deps.cache_ensure.registry_requests", + "subsystem": "dependency", + "runtime_regime": "cache_ensure", + "fixture_id": "deps.cache-hit.small.v1", + "measurement_kind": "count", + "unit": "count", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 1}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "absolute_ceiling", + "threshold_origin": "product_budget", + "timing_boundary": { + "start_event": "dependency.cache.ensure.start", + "end_event": "dependency.cache.ensure.complete", + "clock_source": "event_counter", + "evidence_source": "broker_dependency_audit", + "included_phases": ["lookup", "fetch_optional"] + } + }, + { + "metric_id": "metric.audit.verify_current_segment.wall_ms", + "subsystem": "audit", + "runtime_regime": "verify_current_segment", + "fixture_id": "audit.ledger.standard.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 10, "median_max_authoritative": true}, + "budget_class": "regression-budget", + "threshold": {"max_regression_percent": 15}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "baseline_source": "reviewed_repeated_samples", + "baseline_ref": "baselines/metric.audit.verify_current_segment.wall_ms.v1.json", + "comparison_method": "median_regression_with_noise_floor", + "practical_noise_floor": 10, + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "audit.verify_current_segment.start", + "end_event": "audit.verify_current_segment.complete", + "clock_source": "monotonic", + "evidence_source": "auditd_runtime", + "included_phases": ["seal_load", "verify", "report_persist"] + } + }, + { + "metric_id": "metric.audit.finalize_verify.wall_ms", + "subsystem": "audit", + "runtime_regime": "verify_finalize", + "fixture_id": "audit.ledger.standard.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 10, "median_max_authoritative": true}, + "budget_class": "regression-budget", + "threshold": {"max_regression_percent": 15}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "baseline_source": "reviewed_repeated_samples", + "baseline_ref": "baselines/metric.audit.finalize_verify.wall_ms.v1.json", + "comparison_method": "median_regression_with_noise_floor", + "practical_noise_floor": 10, + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "audit.finalize_verify.start", + "end_event": "audit.finalize_verify.complete", + "clock_source": "monotonic", + "evidence_source": "auditd_runtime", + "included_phases": ["finalize", "verify"] + } + }, + { + "metric_id": "metric.protocol.schema_validation.wall_ms", + "subsystem": "protocol", + "runtime_regime": "schema_validation", + "fixture_id": "protocol.schema.bundle.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 5, "median_max_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 2000}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "max_ceiling", + "threshold_origin": "product_budget", + "timing_boundary": { + "start_event": "protocol.schema.validation.start", + "end_event": "protocol.schema.validation.complete", + "clock_source": "monotonic", + "evidence_source": "go_test_timing", + "included_phases": ["bundle_load", "schema_validation"] + } + }, + { + "metric_id": "metric.protocol.fixture_parity.wall_ms", + "subsystem": "protocol", + "runtime_regime": "fixture_parity", + "fixture_id": "protocol.fixture.parity.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 5, "median_max_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 2000}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "max_ceiling", + "threshold_origin": "product_budget", + "timing_boundary": { + "start_event": "protocol.fixture.parity.start", + "end_event": "protocol.fixture.parity.complete", + "clock_source": "monotonic", + "evidence_source": "runner_node_test_timing", + "included_phases": ["fixture_manifest_load", "schema_validation", "parity_checks"] + } + } + ] +} diff --git a/tools/perfcontracts/contracts/external-anchor.v1.json b/tools/perfcontracts/contracts/external-anchor.v1.json new file mode 100644 index 00000000..e1dc0aaa --- /dev/null +++ b/tools/perfcontracts/contracts/external-anchor.v1.json @@ -0,0 +1,178 @@ +{ + "schema_version":"runecode.performance.contract.v1", + "contract_id": "performance.external-anchor.v1", + "surface": "external-anchor", + "metrics": [ + { + "metric_id": "metric.anchor.prepare.latency.p95_ms", + "subsystem": "external-anchor", + "runtime_regime": "prepare", + "fixture_id": "anchor.fast-complete.stub.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 500}, + "lane_authority": "contract_pending_dependency", + "activation_state": "contract_pending_dependency", + "comparison_method": "p95_ceiling", + "threshold_origin": "temporary_guardrail", + "timing_boundary": { + "start_event": "external_anchor.prepare.start", + "end_event": "external_anchor.prepare.persisted", + "clock_source": "monotonic", + "evidence_source": "broker_events", + "included_phases": ["prepare", "persist"] + }, + "notes": "Pending CHG-2026-025 external anchoring authoritative path" + }, + { + "metric_id": "metric.anchor.execute.deferred.handoff.p95_ms", + "subsystem": "external-anchor", + "runtime_regime": "execute_deferred", + "fixture_id": "anchor.deferred.stub.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 500}, + "lane_authority": "contract_pending_dependency", + "activation_state": "contract_pending_dependency", + "comparison_method": "p95_ceiling", + "threshold_origin": "temporary_guardrail", + "timing_boundary": { + "start_event": "external_anchor.execute.start", + "end_event": "external_anchor.execute.deferred_persisted", + "clock_source": "monotonic", + "evidence_source": "broker_events", + "included_phases": ["execute", "deferred_handoff"] + }, + "notes": "Pending CHG-2026-025 external anchoring authoritative path" + }, + { + "metric_id": "metric.anchor.execute.completed.p95_ms", + "subsystem": "external-anchor", + "runtime_regime": "execute_completed", + "fixture_id": "anchor.fast-complete.stub.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "regression-budget", + "threshold": {"max_regression_percent": 15}, + "lane_authority": "contract_pending_dependency", + "activation_state": "contract_pending_dependency", + "comparison_method": "median_regression_with_noise_floor", + "practical_noise_floor": 5, + "threshold_origin": "temporary_guardrail", + "timing_boundary": { + "start_event": "external_anchor.execute.start", + "end_event": "external_anchor.execute.completed_persisted", + "clock_source": "monotonic", + "evidence_source": "broker_events", + "included_phases": ["execute", "proof_admission", "persist"] + }, + "notes": "Pending CHG-2026-025 external anchoring authoritative path" + }, + { + "metric_id": "metric.anchor.deferred.visibility.p95_ms", + "subsystem": "external-anchor", + "runtime_regime": "deferred_visibility", + "fixture_id": "anchor.deferred.stub.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "regression-budget", + "threshold": {"max_regression_percent": 15}, + "lane_authority": "contract_pending_dependency", + "activation_state": "contract_pending_dependency", + "comparison_method": "median_regression_with_noise_floor", + "practical_noise_floor": 5, + "threshold_origin": "temporary_guardrail", + "timing_boundary": { + "start_event": "external_anchor.execute.deferred_persisted", + "end_event": "external_anchor.execute.completed_visible", + "clock_source": "monotonic", + "evidence_source": "broker_get_watch_surfaces", + "included_phases": ["deferred_background_execute", "visibility_projection"] + }, + "notes": "Pending CHG-2026-025 external anchoring authoritative path" + }, + { + "metric_id": "metric.anchor.receipt_admission.unchanged_seal.p95_ms", + "subsystem": "external-anchor", + "runtime_regime": "receipt_admission_unchanged_seal", + "fixture_id": "anchor.fast-complete.stub.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "regression-budget", + "threshold": {"max_regression_percent": 15}, + "lane_authority": "contract_pending_dependency", + "activation_state": "contract_pending_dependency", + "comparison_method": "median_regression_with_noise_floor", + "practical_noise_floor": 5, + "threshold_origin": "temporary_guardrail", + "timing_boundary": { + "start_event": "external_anchor.receipt_admission.start", + "end_event": "external_anchor.receipt_admission.persisted", + "clock_source": "monotonic", + "evidence_source": "auditd_verifier_runtime", + "included_phases": ["preverified_seal_lookup", "receipt_admission", "persistence"] + }, + "notes": "Pending CHG-2026-025 external anchoring authoritative path" + }, + { + "metric_id": "metric.anchor.network_io_under_ledger_lock.count", + "subsystem": "external-anchor", + "runtime_regime": "lock_boundary", + "fixture_id": "anchor.deferred.stub.v1", + "measurement_kind": "count", + "unit": "count", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {}, + "budget_class": "exact", + "threshold": {"exact_value": 0}, + "lane_authority": "contract_pending_dependency", + "activation_state": "contract_pending_dependency", + "comparison_method": "exact_match", + "threshold_origin": "product_budget", + "timing_boundary": { + "start_event": "external_anchor.runtime.execute.start", + "end_event": "external_anchor.runtime.execute.complete", + "clock_source": "event_counter", + "evidence_source": "lock_boundary_instrumentation", + "included_phases": ["snapshot_outside_lock", "network_execute"] + }, + "notes": "Pending CHG-2026-025 external anchoring authoritative path" + }, + { + "metric_id": "metric.anchor.verifier_bypass.count", + "subsystem": "external-anchor", + "runtime_regime": "authoritative_verifier_admission", + "fixture_id": "anchor.fast-complete.stub.v1", + "measurement_kind": "count", + "unit": "count", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {}, + "budget_class": "exact", + "threshold": {"exact_value": 0}, + "lane_authority": "contract_pending_dependency", + "activation_state": "contract_pending_dependency", + "comparison_method": "exact_match", + "threshold_origin": "product_budget", + "timing_boundary": { + "start_event": "external_anchor.receipt_admission.start", + "end_event": "external_anchor.receipt_admission.persisted", + "clock_source": "event_counter", + "evidence_source": "verifier_admission_instrumentation", + "included_phases": ["preverified_seal_lookup", "receipt_admission"] + }, + "notes": "Pending CHG-2026-025 external anchoring authoritative path" + } + ] +} diff --git a/tools/perfcontracts/contracts/gateway-secrets.v1.json b/tools/perfcontracts/contracts/gateway-secrets.v1.json new file mode 100644 index 00000000..6e6c991d --- /dev/null +++ b/tools/perfcontracts/contracts/gateway-secrets.v1.json @@ -0,0 +1,76 @@ +{ + "schema_version":"runecode.performance.contract.v1", + "contract_id": "performance.gateway-secrets.v1", + "surface": "gateway-secrets", + "metrics": [ + { + "metric_id": "metric.gateway.model_invoke.overhead.p95_ms", + "subsystem": "model-gateway", + "runtime_regime": "stubbed_provider_invoke", + "fixture_id": "workflow.first-party-minimal.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 100}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "temporary_guardrail", + "timing_boundary": { + "start_event": "gateway.invoke.request.accepted", + "end_event": "gateway.invoke.response.projected", + "clock_source": "monotonic", + "evidence_source": "broker_events", + "included_phases": ["admission", "translation", "stubbed_invoke", "projection"] + } + }, + { + "metric_id": "metric.secrets.lease_issue.p95_ms", + "subsystem": "secrets", + "runtime_regime": "stubbed_secret_ingress", + "fixture_id": "workflow.first-party-minimal.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 150}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "temporary_guardrail", + "timing_boundary": { + "start_event": "secrets.lease.issue.start", + "end_event": "secrets.lease.issue.persisted", + "clock_source": "monotonic", + "evidence_source": "secretsd_events", + "included_phases": ["policy", "issue", "persist"] + } + }, + { + "metric_id": "metric.secrets.ingress.prepare_submit.p95_ms", + "subsystem": "secrets", + "runtime_regime": "stubbed_secret_ingress", + "fixture_id": "workflow.first-party-minimal.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 300}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "temporary_guardrail", + "timing_boundary": { + "start_event": "secrets.ingress.prepare.start", + "end_event": "secrets.ingress.submit.persisted", + "clock_source": "monotonic", + "evidence_source": "secretsd_events", + "included_phases": ["prepare", "submit", "persist"] + } + } + ] +} diff --git a/tools/perfcontracts/contracts/launcher.v1.json b/tools/perfcontracts/contracts/launcher.v1.json new file mode 100644 index 00000000..8fded66b --- /dev/null +++ b/tools/perfcontracts/contracts/launcher.v1.json @@ -0,0 +1,99 @@ +{ + "schema_version":"runecode.performance.contract.v1", + "contract_id": "performance.launcher.v1", + "surface": "launcher", + "metrics": [ + { + "metric_id": "metric.launcher.microvm.cold_start.wall_ms", + "subsystem": "launcher", + "runtime_regime": "microvm_cold", + "fixture_id": "launcher.microvm.signed-runtime.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 10, "median_max_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 8000}, + "lane_authority": "informational_until_stable", + "activation_state": "informational", + "comparison_method": "max_ceiling", + "threshold_origin": "temporary_guardrail", + "timing_boundary": { + "start_event": "launcher.runtime_admission.start", + "end_event": "broker.runtime_ready.projected", + "clock_source": "monotonic", + "evidence_source": "runtime_evidence_and_broker_projection", + "included_phases": ["signature_verification", "component_digest_verification", "admission", "launch", "broker_projection"] + } + }, + { + "metric_id": "metric.launcher.microvm.warm_start.wall_ms", + "subsystem": "launcher", + "runtime_regime": "microvm_warm", + "fixture_id": "launcher.microvm.signed-runtime.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 10, "median_max_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 3000}, + "lane_authority": "informational_until_stable", + "activation_state": "informational", + "comparison_method": "max_ceiling", + "threshold_origin": "temporary_guardrail", + "timing_boundary": { + "start_event": "launcher.verified_cache_lookup.start", + "end_event": "broker.runtime_ready.projected", + "clock_source": "monotonic", + "evidence_source": "runtime_evidence_and_broker_projection", + "included_phases": ["verified_cache_hit", "launch", "broker_projection"] + } + }, + { + "metric_id": "metric.launcher.container.cold_start.wall_ms", + "subsystem": "launcher", + "runtime_regime": "container_cold", + "fixture_id": "launcher.container.signed-runtime.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 10, "median_max_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 4000}, + "lane_authority": "informational_until_stable", + "activation_state": "informational", + "comparison_method": "max_ceiling", + "threshold_origin": "temporary_guardrail", + "timing_boundary": { + "start_event": "launcher.runtime_admission.start", + "end_event": "broker.runtime_ready.projected", + "clock_source": "monotonic", + "evidence_source": "runtime_evidence_and_broker_projection", + "included_phases": ["signature_verification", "component_digest_verification", "admission", "launch", "broker_projection"] + } + }, + { + "metric_id": "metric.launcher.container.warm_start.wall_ms", + "subsystem": "launcher", + "runtime_regime": "container_warm", + "fixture_id": "launcher.container.signed-runtime.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 10, "median_max_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 2000}, + "lane_authority": "informational_until_stable", + "activation_state": "informational", + "comparison_method": "max_ceiling", + "threshold_origin": "temporary_guardrail", + "timing_boundary": { + "start_event": "launcher.verified_cache_lookup.start", + "end_event": "broker.runtime_ready.projected", + "clock_source": "monotonic", + "evidence_source": "runtime_evidence_and_broker_projection", + "included_phases": ["verified_cache_hit", "launch", "broker_projection"] + } + } + ] +} diff --git a/tools/perfcontracts/contracts/runner-workflow.v1.json b/tools/perfcontracts/contracts/runner-workflow.v1.json new file mode 100644 index 00000000..9aad8fb0 --- /dev/null +++ b/tools/perfcontracts/contracts/runner-workflow.v1.json @@ -0,0 +1,243 @@ +{ + "schema_version":"runecode.performance.contract.v1", + "contract_id": "performance.runner-workflow.v1", + "surface": "runner-workflow", + "metrics": [ + { + "metric_id": "metric.runner.boundary_check.wall_ms", + "subsystem": "runner", + "runtime_regime": "boundary_check", + "fixture_id": "runner.boundary.minimal.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "measurement_profile": "linux_shared_ci", + "sampling_policy": {"trials": 5, "median_max_authoritative": true}, + "budget_class": "hybrid-budget", + "threshold": {"max_value": 5000, "max_regression_percent": 15}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "baseline_source": "reviewed_repeated_samples", + "baseline_ref": "baselines/metric.runner.boundary_check.wall_ms.v1.json", + "comparison_method": "median_plus_regression", + "practical_noise_floor": 30, + "threshold_origin": "product_budget", + "timing_boundary": { + "start_event": "runner.boundary_check.start", + "end_event": "runner.boundary_check.finish", + "clock_source": "monotonic", + "evidence_source": "command_timing", + "included_phases": ["runner_checks"] + } + }, + { + "metric_id": "metric.runner.protocol_fixtures.wall_ms", + "subsystem": "runner", + "runtime_regime": "protocol_fixtures", + "fixture_id": "workflow.first-party-minimal.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "measurement_profile": "linux_shared_ci", + "sampling_policy": {"trials": 5, "median_max_authoritative": true}, + "budget_class": "hybrid-budget", + "threshold": {"max_value": 10000, "max_regression_percent": 15}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "baseline_source": "reviewed_repeated_samples", + "baseline_ref": "baselines/metric.runner.protocol_fixtures.wall_ms.v1.json", + "comparison_method": "median_plus_regression", + "practical_noise_floor": 50, + "threshold_origin": "product_budget", + "timing_boundary": { + "start_event": "runner.protocol_fixtures.start", + "end_event": "runner.protocol_fixtures.finish", + "clock_source": "monotonic", + "evidence_source": "command_timing", + "included_phases": ["fixture_load", "schema_validation", "parity_checks"] + } + }, + { + "metric_id": "metric.runner.cold_start.minimal_workflow.wall_ms", + "subsystem": "runner", + "runtime_regime": "cold_start", + "fixture_id": "workflow.first-party-minimal.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "measurement_profile": "linux_shared_ci", + "sampling_policy": {"trials": 10, "median_max_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 1000}, + "lane_authority": "informational_until_stable", + "activation_state": "informational", + "comparison_method": "max_ceiling", + "threshold_origin": "temporary_guardrail", + "timing_boundary": { + "start_event": "runner.startup.start", + "end_event": "runner.plan.first_durable_checkpoint", + "clock_source": "monotonic", + "evidence_source": "runner_runtime_events", + "included_phases": ["plan_load", "scheduler_bootstrap", "checkpoint_projection"] + } + }, + { + "metric_id": "metric.workflow.mvp_execution.supported_path.wall_ms", + "subsystem": "workflow", + "runtime_regime": "supported_mvp_path", + "fixture_id": "workflow.first-party-minimal.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "measurement_profile": "linux_shared_ci", + "sampling_policy": {"trials": 10, "median_max_authoritative": true}, + "budget_class": "regression-budget", + "threshold": {"max_regression_percent": 15}, + "lane_authority": "contract_pending_dependency", + "activation_state": "contract_pending_dependency", + "comparison_method": "median_regression_with_noise_floor", + "practical_noise_floor": 10, + "threshold_origin": "temporary_guardrail", + "timing_boundary": { + "start_event": "workflow.trigger.accepted", + "end_event": "workflow.completed.persisted", + "clock_source": "monotonic", + "evidence_source": "broker_events", + "included_phases": ["trigger", "execution", "completion_projection"] + } + }, + { + "metric_id": "metric.workflow.chg049.first_party_beta_slice.wall_ms", + "subsystem": "workflow", + "runtime_regime": "first_party_beta_slice", + "fixture_id": "workflow.first-party-minimal.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "measurement_profile": "linux_shared_ci", + "sampling_policy": {"trials": 10, "median_max_authoritative": true}, + "budget_class": "regression-budget", + "threshold": {"max_regression_percent": 15}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "baseline_source": "reviewed_repeated_samples", + "baseline_ref": "baselines/metric.workflow.chg049.first_party_beta_slice.wall_ms.v1.json", + "comparison_method": "median_regression_with_noise_floor", + "practical_noise_floor": 10, + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "workflow.first_party_beta.start", + "end_event": "workflow.first_party_beta.completed", + "clock_source": "monotonic", + "evidence_source": "runner_runtime_events", + "included_phases": ["plan_load", "scheduler_dispatch", "state_projection"] + } + }, + { + "metric_id": "metric.workflow.chg050.compile.wall_ms", + "subsystem": "workflow", + "runtime_regime": "chg050_compile", + "fixture_id": "workflow.chg050-compile.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "measurement_profile": "linux_shared_ci", + "sampling_policy": {"trials": 10, "median_max_authoritative": true}, + "budget_class": "regression-budget", + "threshold": {"max_regression_percent": 15}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "baseline_source": "reviewed_repeated_samples", + "baseline_ref": "baselines/metric.workflow.chg050.compile.wall_ms.v1.json", + "comparison_method": "median_regression_with_noise_floor", + "practical_noise_floor": 10, + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "workflow.compile.start", + "end_event": "workflow.compile.persisted", + "clock_source": "monotonic", + "evidence_source": "broker_events", + "included_phases": ["validation", "canonicalization", "compile", "persist"] + } + }, + { + "metric_id": "metric.workflow.chg050.validation_canonicalization.wall_ms", + "subsystem": "workflow", + "runtime_regime": "chg050_validate_canonicalize", + "fixture_id": "workflow.chg050-compile.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "measurement_profile": "linux_shared_ci", + "sampling_policy": {"trials": 10, "median_max_authoritative": true}, + "budget_class": "regression-budget", + "threshold": {"max_regression_percent": 15}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "baseline_source": "reviewed_repeated_samples", + "baseline_ref": "baselines/metric.workflow.chg050.validation_canonicalization.wall_ms.v1.json", + "comparison_method": "median_regression_with_noise_floor", + "practical_noise_floor": 10, + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "workflow.validate.start", + "end_event": "workflow.canonicalization.complete", + "clock_source": "monotonic", + "evidence_source": "compile_pipeline_events", + "included_phases": ["schema_validation", "canonicalization"] + } + }, + { + "metric_id": "metric.workflow.chg050.runplan_persist_load.wall_ms", + "subsystem": "workflow", + "runtime_regime": "chg050_persist_load", + "fixture_id": "workflow.chg050-compile.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "measurement_profile": "linux_shared_ci", + "sampling_policy": {"trials": 10, "median_max_authoritative": true}, + "budget_class": "regression-budget", + "threshold": {"max_regression_percent": 15}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "baseline_source": "reviewed_repeated_samples", + "baseline_ref": "baselines/metric.workflow.chg050.runplan_persist_load.wall_ms.v1.json", + "comparison_method": "median_regression_with_noise_floor", + "practical_noise_floor": 10, + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "runplan.persist.start", + "end_event": "runplan.load.complete", + "clock_source": "monotonic", + "evidence_source": "broker_store_records", + "included_phases": ["persist", "authority_record", "cache_key_lookup", "load"] + } + }, + { + "metric_id": "metric.workflow.chg050.runner_start_immutable_runplan.wall_ms", + "subsystem": "workflow", + "runtime_regime": "chg050_start_from_immutable_runplan", + "fixture_id": "workflow.chg050-compile.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "measurement_profile": "linux_shared_ci", + "sampling_policy": {"trials": 10, "median_max_authoritative": true}, + "budget_class": "regression-budget", + "threshold": {"max_regression_percent": 15}, + "lane_authority": "contract_pending_dependency", + "activation_state": "contract_pending_dependency", + "comparison_method": "median_regression_with_noise_floor", + "practical_noise_floor": 10, + "threshold_origin": "temporary_guardrail", + "timing_boundary": { + "start_event": "runner.immutable_runplan.start", + "end_event": "runner.immutable_runplan.ready", + "clock_source": "monotonic", + "evidence_source": "runner_runtime_events", + "included_phases": ["immutable_plan_load", "startup", "attach_ready_projection"] + } + } + ] +} diff --git a/tools/perfcontracts/contracts/tui.v1.json b/tools/perfcontracts/contracts/tui.v1.json new file mode 100644 index 00000000..bc368e72 --- /dev/null +++ b/tools/perfcontracts/contracts/tui.v1.json @@ -0,0 +1,289 @@ +{ + "schema_version":"runecode.performance.contract.v1", + "contract_id": "performance.tui.v1", + "surface": "tui", + "metrics": [ + { + "metric_id": "metric.tui.attach.quiet.p95_ms", + "subsystem": "tui", + "runtime_regime": "attach_quiet", + "fixture_id": "tui.empty.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 500}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "product_budget", + "timing_boundary": { + "start_event": "tui.process.spawn", + "end_event": "broker.attach.ready.projected_frame", + "clock_source": "monotonic", + "evidence_source": "pty_transcript", + "included_phases": ["launch", "broker_attach", "first_settled_frame"] + } + }, + { + "metric_id": "metric.tui.attach.waiting.p95_ms", + "subsystem": "tui", + "runtime_regime": "attach_waiting", + "fixture_id": "tui.waiting.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 500}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "product_budget", + "timing_boundary": { + "start_event": "tui.process.spawn", + "end_event": "broker.attach.ready.projected_frame", + "clock_source": "monotonic", + "evidence_source": "pty_transcript", + "included_phases": ["launch", "broker_attach", "first_settled_frame"] + } + }, + { + "metric_id": "metric.tui.key_response.quiet.p95_ms", + "subsystem": "tui", + "runtime_regime": "key_response_quiet", + "fixture_id": "tui.empty.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 50}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "product_budget", + "timing_boundary": { + "start_event": "tui.key.injected", + "end_event": "broker.attach.ready.frame_delta", + "clock_source": "monotonic", + "evidence_source": "pty_transcript", + "included_phases": ["input_dispatch", "update", "render", "frame_flush"] + } + }, + { + "metric_id": "metric.tui.key_response.waiting.p95_ms", + "subsystem": "tui", + "runtime_regime": "key_response_waiting", + "fixture_id": "tui.waiting.v1", + "measurement_kind": "latency", + "unit": "ms", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"trials": 30, "p95_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 75}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "comparison_method": "p95_ceiling", + "threshold_origin": "product_budget", + "timing_boundary": { + "start_event": "tui.key.injected", + "end_event": "broker.attach.ready.frame_delta", + "clock_source": "monotonic", + "evidence_source": "pty_transcript", + "included_phases": ["input_dispatch", "update", "render", "frame_flush"] + } + }, + { + "metric_id": "metric.tui.idle_cpu.empty.avg_pct", + "subsystem": "tui", + "runtime_regime": "empty_idle", + "fixture_id": "tui.empty.v1", + "measurement_kind": "cpu", + "unit": "percent", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"warmup_millis": 3000, "observation_window_millis": 20000, "observation_windows": 3, "median_max_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 2}, + "lane_authority": "informational_until_stable", + "activation_state": "informational", + "comparison_method": "window_average", + "threshold_origin": "investigation_baseline", + "timing_boundary": { + "start_event": "tui.warmup.complete", + "end_event": "cpu.observation.window.complete", + "clock_source": "monotonic", + "evidence_source": "proc_stat", + "included_phases": ["idle_observation"] + } + }, + { + "metric_id": "metric.tui.idle_cpu.empty.max_pct", + "subsystem": "tui", + "runtime_regime": "empty_idle", + "fixture_id": "tui.empty.v1", + "measurement_kind": "cpu", + "unit": "percent", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"warmup_millis": 3000, "observation_window_millis": 20000, "observation_windows": 3, "median_max_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 4}, + "lane_authority": "informational_until_stable", + "activation_state": "informational", + "comparison_method": "window_max", + "threshold_origin": "investigation_baseline", + "timing_boundary": { + "start_event": "tui.warmup.complete", + "end_event": "cpu.observation.window.complete", + "clock_source": "monotonic", + "evidence_source": "proc_stat", + "included_phases": ["idle_observation"] + } + }, + { + "metric_id": "metric.tui.idle_cpu.waiting.avg_pct", + "subsystem": "tui", + "runtime_regime": "waiting_state", + "fixture_id": "tui.waiting.v1", + "measurement_kind": "cpu", + "unit": "percent", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"warmup_millis": 3000, "observation_window_millis": 20000, "observation_windows": 3, "median_max_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 8}, + "lane_authority": "informational_until_stable", + "activation_state": "informational", + "comparison_method": "window_average", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "tui.warmup.complete", + "end_event": "cpu.observation.window.complete", + "clock_source": "monotonic", + "evidence_source": "proc_stat", + "included_phases": ["waiting_observation"] + } + }, + { + "metric_id": "metric.tui.idle_cpu.waiting.max_pct", + "subsystem": "tui", + "runtime_regime": "waiting_state", + "fixture_id": "tui.waiting.v1", + "measurement_kind": "cpu", + "unit": "percent", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"warmup_millis": 3000, "observation_window_millis": 20000, "observation_windows": 3, "median_max_authoritative": true}, + "budget_class": "absolute-budget", + "threshold": {"max_value": 12}, + "lane_authority": "informational_until_stable", + "activation_state": "informational", + "comparison_method": "window_max", + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "tui.warmup.complete", + "end_event": "cpu.observation.window.complete", + "clock_source": "monotonic", + "evidence_source": "proc_stat", + "included_phases": ["waiting_observation"] + } + }, + { + "metric_id": "metric.tui.render.shell_view_empty.ns_op", + "subsystem": "tui", + "runtime_regime": "render_empty", + "fixture_id": "tui.empty.v1", + "measurement_kind": "microbenchmark", + "unit": "ns/op", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"repeated_samples": 10}, + "budget_class": "regression-budget", + "threshold": {"max_regression_percent": 15}, + "lane_authority": "required_shared_linux", + "activation_state": "defined", + "comparison_method": "median_regression_with_noise_floor", + "practical_noise_floor": 1000, + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "bench.iteration.start", + "end_event": "bench.iteration.end", + "clock_source": "go_benchmark_timer", + "evidence_source": "go_test_bench_output", + "included_phases": ["render"] + } + }, + { + "metric_id": "metric.tui.render.shell_view_waiting.ns_op", + "subsystem": "tui", + "runtime_regime": "waiting_state_render", + "fixture_id": "tui.waiting.v1", + "measurement_kind": "microbenchmark", + "unit": "ns/op", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"repeated_samples": 10}, + "budget_class": "regression-budget", + "threshold": {"max_regression_percent": 15}, + "lane_authority": "required_shared_linux", + "activation_state": "required", + "baseline_source": "reviewed_repeated_samples", + "baseline_ref": "baselines/metric.tui.render.shell_view_waiting.ns_op.v1.json", + "comparison_method": "median_regression_with_noise_floor", + "practical_noise_floor": 1000, + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "bench.iteration.start", + "end_event": "bench.iteration.end", + "clock_source": "go_benchmark_timer", + "evidence_source": "go_test_bench_output", + "included_phases": ["render"] + } + }, + { + "metric_id": "metric.tui.update.shell_watch_apply.ns_op", + "subsystem": "tui", + "runtime_regime": "watch_apply", + "fixture_id": "tui.waiting.v1", + "measurement_kind": "microbenchmark", + "unit": "ns/op", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"repeated_samples": 10}, + "budget_class": "regression-budget", + "threshold": {"max_regression_percent": 15}, + "lane_authority": "required_shared_linux", + "activation_state": "defined", + "comparison_method": "median_regression_with_noise_floor", + "practical_noise_floor": 1000, + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "bench.iteration.start", + "end_event": "bench.iteration.end", + "clock_source": "go_benchmark_timer", + "evidence_source": "go_test_bench_output", + "included_phases": ["update"] + } + }, + { + "metric_id": "metric.tui.update.build_palette_entries.ns_op", + "subsystem": "tui", + "runtime_regime": "palette_entries", + "fixture_id": "tui.waiting.v1", + "measurement_kind": "microbenchmark", + "unit": "ns/op", + "authoritative_environment": "linux_shared_ci", + "sampling_policy": {"repeated_samples": 10}, + "budget_class": "regression-budget", + "threshold": {"max_regression_percent": 15}, + "lane_authority": "required_shared_linux", + "activation_state": "defined", + "comparison_method": "median_regression_with_noise_floor", + "practical_noise_floor": 1000, + "threshold_origin": "first_calibration", + "timing_boundary": { + "start_event": "bench.iteration.start", + "end_event": "bench.iteration.end", + "clock_source": "go_benchmark_timer", + "evidence_source": "go_test_bench_output", + "included_phases": ["update"] + } + } + ] +} diff --git a/tools/perfcontracts/fixtures/inventory.v1.json b/tools/perfcontracts/fixtures/inventory.v1.json new file mode 100644 index 00000000..20392a91 --- /dev/null +++ b/tools/perfcontracts/fixtures/inventory.v1.json @@ -0,0 +1,27 @@ +{ + "schema_version":"runecode.performance.fixtures.v1", + "fixtures": [ + {"fixture_id": "tui.empty.v1", "surface": "tui", "runtime_regime": "empty_idle", "status": "mvp_reviewed"}, + {"fixture_id": "tui.waiting.v1", "surface": "tui", "runtime_regime": "waiting_state", "status": "mvp_reviewed"}, + {"fixture_id": "broker.unary.beta-small.v1", "surface": "broker", "runtime_regime": "unary_api", "status": "mvp_reviewed"}, + {"fixture_id": "broker.watch.run.snapshot-follow.v1", "surface": "broker", "runtime_regime": "watch_run", "status": "mvp_reviewed"}, + {"fixture_id": "broker.watch.approval.snapshot-follow.v1", "surface": "broker", "runtime_regime": "watch_approval", "status": "mvp_reviewed"}, + {"fixture_id": "broker.watch.session.snapshot-follow.v1", "surface": "broker", "runtime_regime": "watch_session", "status": "mvp_reviewed"}, + {"fixture_id": "broker.watch.turn-execution.snapshot-follow.v1", "surface": "broker", "runtime_regime": "watch_turn_execution", "status": "mvp_reviewed"}, + {"fixture_id": "workflow.first-party-minimal.v1", "surface": "runner-workflow", "runtime_regime": "minimal_workflow", "status": "mvp_reviewed"}, + {"fixture_id": "workflow.chg050-compile.v1", "surface": "runner-workflow", "runtime_regime": "chg050_compile", "status": "mvp_reviewed"}, + {"fixture_id": "runner.boundary.minimal.v1", "surface": "runner-workflow", "runtime_regime": "boundary_check", "status": "mvp_reviewed"}, + {"fixture_id": "launcher.microvm.signed-runtime.v1", "surface": "launcher", "runtime_regime": "microvm_startup", "status": "mvp_reviewed"}, + {"fixture_id": "launcher.container.signed-runtime.v1", "surface": "launcher", "runtime_regime": "container_startup", "status": "mvp_reviewed"}, + {"fixture_id": "deps.cache-miss.small.v1", "surface": "dependency", "runtime_regime": "cache_miss", "status": "mvp_reviewed"}, + {"fixture_id": "deps.cache-hit.small.v1", "surface": "dependency", "runtime_regime": "cache_hit", "status": "mvp_reviewed"}, + {"fixture_id": "deps.coalesced-miss.small.v1", "surface": "dependency", "runtime_regime": "coalesced_miss", "status": "mvp_reviewed"}, + {"fixture_id": "audit.ledger.standard.v1", "surface": "audit", "runtime_regime": "verify_finalize", "status": "mvp_reviewed"}, + {"fixture_id": "protocol.schema.bundle.v1", "surface": "protocol", "runtime_regime": "schema_validation", "status": "mvp_reviewed"}, + {"fixture_id": "protocol.fixture.parity.v1", "surface": "protocol", "runtime_regime": "fixture_parity", "status": "mvp_reviewed"}, + {"fixture_id": "anchor.fast-complete.stub.v1", "surface": "external-anchor", "runtime_regime": "execute_fast_complete", "status": "mvp_reviewed"}, + {"fixture_id": "anchor.deferred.stub.v1", "surface": "external-anchor", "runtime_regime": "execute_deferred", "status": "mvp_reviewed"}, + {"fixture_id": "attestation.cold.signed-runtime.v1", "surface": "attestation", "runtime_regime": "cold_path", "status": "mvp_reviewed"}, + {"fixture_id": "attestation.warm.signed-runtime.v1", "surface": "attestation", "runtime_regime": "warm_path", "status": "mvp_reviewed"} + ] +} diff --git a/tools/perfcontracts/main.go b/tools/perfcontracts/main.go new file mode 100644 index 00000000..52f27d66 --- /dev/null +++ b/tools/perfcontracts/main.go @@ -0,0 +1,181 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/runecode-ai/runecode/internal/perfcontracts" +) + +type config struct { + contractsRoot string + checkOutput string + lane string + metricIDs []string +} + +func main() { + if err := run(os.Args[1:]); err != nil { + var usage usageError + if errors.As(err, &usage) { + fmt.Fprintf(os.Stderr, "perfcontracts usage error: %v\n", err) + os.Exit(2) + } + fmt.Fprintf(os.Stderr, "perfcontracts check failed: %v\n", err) + os.Exit(1) + } +} + +func run(args []string) error { + cfg, err := parseArgs(args) + if err != nil { + return err + } + manifest, inventory, contracts, baselinesByMetric, err := loadContractSet(cfg.contractsRoot) + if err != nil { + return err + } + if err := perfcontracts.ValidateWithBaselines(manifest, inventory, contracts, baselinesByMetric); err != nil { + return err + } + checkOutput, err := perfcontracts.LoadCheckOutput(cfg.checkOutput) + if err != nil { + return err + } + filtered := filterContractsForLane(contracts, cfg.lane, cfg.metricIDs) + if countMetrics(filtered) == 0 { + return fmt.Errorf("no required metrics selected for lane %q", cfg.lane) + } + violations := perfcontracts.Evaluate(checkOutput, filtered, baselinesByMetric) + if len(violations) > 0 { + for _, violation := range violations { + fmt.Fprintf(os.Stderr, "- %s: %s\n", violation.MetricID, violation.Reason) + } + return fmt.Errorf("%d performance contract violation(s)", len(violations)) + } + fmt.Printf("Performance contracts check passed (%d metrics evaluated).\n", countMetrics(filtered)) + return nil +} + +func parseArgs(args []string) (config, error) { + fs := flag.NewFlagSet("perfcontracts", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + contractsRoot := fs.String("contracts-root", filepath.FromSlash("tools/perfcontracts"), "path to performance contracts root") + checkOutput := fs.String("check-output", "", "path to performance check output json") + lane := fs.String("lane", "required_shared_linux", "lane authority to enforce") + metricIDFlags := multiStringFlag{} + fs.Var(&metricIDFlags, "metric-id", "optional metric_id to enforce; repeatable") + if err := fs.Parse(args); err != nil { + return config{}, usageError{err} + } + if strings.TrimSpace(*checkOutput) == "" { + return config{}, usageError{errors.New("--check-output is required")} + } + return config{contractsRoot: strings.TrimSpace(*contractsRoot), checkOutput: strings.TrimSpace(*checkOutput), lane: strings.TrimSpace(*lane), metricIDs: metricIDFlags.values()}, nil +} + +func loadContractSet(root string) (perfcontracts.Manifest, perfcontracts.FixtureInventory, []perfcontracts.ContractFile, map[string]perfcontracts.BaselineFile, error) { + manifest, err := perfcontracts.LoadManifest(root) + if err != nil { + return perfcontracts.Manifest{}, perfcontracts.FixtureInventory{}, nil, nil, err + } + inventory, err := perfcontracts.LoadFixtureInventory(root, manifest.FixtureInventoryRef) + if err != nil { + return perfcontracts.Manifest{}, perfcontracts.FixtureInventory{}, nil, nil, err + } + contracts := make([]perfcontracts.ContractFile, 0, len(manifest.Contracts)) + for _, entry := range manifest.Contracts { + contract, loadErr := perfcontracts.LoadContract(root, entry.Path) + if loadErr != nil { + return perfcontracts.Manifest{}, perfcontracts.FixtureInventory{}, nil, nil, loadErr + } + contracts = append(contracts, contract) + } + baselinesByMetric := map[string]perfcontracts.BaselineFile{} + for _, entry := range manifest.Baselines { + baseline, loadErr := perfcontracts.LoadBaseline(root, entry.Path) + if loadErr != nil { + return perfcontracts.Manifest{}, perfcontracts.FixtureInventory{}, nil, nil, loadErr + } + baselinesByMetric[entry.MetricID] = baseline + } + return manifest, inventory, contracts, baselinesByMetric, nil +} + +func filterContractsForLane(contracts []perfcontracts.ContractFile, lane string, metricIDs []string) []perfcontracts.ContractFile { + allowedMetrics := metricFilterSet(metricIDs) + + var filtered []perfcontracts.ContractFile + for _, contract := range contracts { + next := perfcontracts.ContractFile{SchemaVersion: contract.SchemaVersion, ContractID: contract.ContractID, Surface: contract.Surface} + for _, metric := range contract.Metrics { + if includeMetric(metric, lane, allowedMetrics) { + next.Metrics = append(next.Metrics, metric) + } + } + if len(next.Metrics) > 0 { + filtered = append(filtered, next) + } + } + return filtered +} + +func metricFilterSet(metricIDs []string) map[string]struct{} { + allowedMetrics := map[string]struct{}{} + for _, metricID := range metricIDs { + trimmed := strings.TrimSpace(metricID) + if trimmed != "" { + allowedMetrics[trimmed] = struct{}{} + } + } + return allowedMetrics +} + +func includeMetric(metric perfcontracts.MetricContract, lane string, allowedMetrics map[string]struct{}) bool { + if metric.LaneAuthority != lane || metric.ActivationState != "required" { + return false + } + if len(allowedMetrics) == 0 { + return true + } + _, ok := allowedMetrics[metric.MetricID] + return ok +} + +func countMetrics(contracts []perfcontracts.ContractFile) int { + total := 0 + for _, contract := range contracts { + total += len(contract.Metrics) + } + return total +} + +type usageError struct{ err error } + +func (u usageError) Error() string { return u.err.Error() } + +func (u usageError) Unwrap() error { return u.err } + +type multiStringFlag struct{ items []string } + +func (m *multiStringFlag) String() string { + if m == nil { + return "" + } + return strings.Join(m.items, ",") +} + +func (m *multiStringFlag) Set(value string) error { + m.items = append(m.items, value) + return nil +} + +func (m *multiStringFlag) values() []string { + out := make([]string, len(m.items)) + copy(out, m.items) + return out +} diff --git a/tools/perfcontracts/main_test.go b/tools/perfcontracts/main_test.go new file mode 100644 index 00000000..6261fd8c --- /dev/null +++ b/tools/perfcontracts/main_test.go @@ -0,0 +1,207 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunPassesForRequiredSharedLinuxMetrics(t *testing.T) { + root := t.TempDir() + writeContractsFixture(t, root) + checkOutput := filepath.Join(root, "check.json") + writeFile(t, checkOutput, `{"schema_version":"v1","measurements":[{"metric_id":"metric.tui.attach.latency.p95","value":420,"unit":"ms"}]}`) + if err := run([]string{"--contracts-root", root, "--check-output", checkOutput, "--lane", "required_shared_linux"}); err != nil { + t.Fatalf("run returned error: %v", err) + } +} + +func TestRunFailsOnThresholdViolation(t *testing.T) { + root := t.TempDir() + writeContractsFixture(t, root) + checkOutput := filepath.Join(root, "check.json") + writeFile(t, checkOutput, `{"schema_version":"v1","measurements":[{"metric_id":"metric.tui.attach.latency.p95","value":999,"unit":"ms"}]}`) + if err := run([]string{"--contracts-root", root, "--check-output", checkOutput, "--lane", "required_shared_linux"}); err == nil { + t.Fatal("run error = nil, want threshold violation") + } +} + +func TestRunIgnoresInformationalAndPendingMetrics(t *testing.T) { + root := t.TempDir() + writeContractsFixture(t, root) + checkOutput := filepath.Join(root, "check.json") + writeFile(t, checkOutput, `{"schema_version":"v1","measurements":[{"metric_id":"metric.tui.attach.latency.p95","value":420,"unit":"ms"},{"metric_id":"metric.broker.watch.latency.p95","value":999,"unit":"ms"},{"metric_id":"metric.anchor.prepare.latency.p95","value":999,"unit":"ms"}]}`) + if err := run([]string{"--contracts-root", root, "--check-output", checkOutput, "--lane", "required_shared_linux"}); err != nil { + t.Fatalf("run returned error for non-required metrics: %v", err) + } +} + +func TestRunFiltersRequiredLaneByMetricID(t *testing.T) { + root := t.TempDir() + writeContractsFixture(t, root) + checkOutput := filepath.Join(root, "check.json") + writeFile(t, checkOutput, `{"schema_version":"v1","measurements":[{"metric_id":"metric.tui.attach.latency.p95","value":420,"unit":"ms"},{"metric_id":"metric.tui.key_response.quiet.p95_ms","value":999,"unit":"ms"}]}`) + appendRequiredMetric(t, root) + if err := run([]string{"--contracts-root", root, "--check-output", checkOutput, "--lane", "required_shared_linux", "--metric-id", "metric.tui.attach.latency.p95"}); err != nil { + t.Fatalf("run returned error for filtered required metric: %v", err) + } +} + +func writeContractsFixture(t *testing.T, root string) { + t.Helper() + contractsDir, baselinesDir := createFixtureDirs(t, root) + writeFixtureManifest(t, root) + writeFixtureInventory(t, root) + writeFixtureContract(t, contractsDir) + writeFixtureBaseline(t, baselinesDir) +} + +func createFixtureDirs(t *testing.T, root string) (string, string) { + t.Helper() + contractsDir := filepath.Join(root, "contracts") + baselinesDir := filepath.Join(root, "baselines") + if err := os.MkdirAll(contractsDir, 0o755); err != nil { + t.Fatalf("MkdirAll contracts: %v", err) + } + if err := os.MkdirAll(baselinesDir, 0o755); err != nil { + t.Fatalf("MkdirAll baselines: %v", err) + } + return contractsDir, baselinesDir +} + +func writeFixtureManifest(t *testing.T, root string) { + t.Helper() + writeFile(t, filepath.Join(root, "manifest.json"), `{ + "schema_version":"runecode.performance.manifest.v1", + "manifest_version":"1", + "change_ref":"CHG-2026-053-9d2b-performance-baselines-verification-gates-v0", + "fixture_inventory_ref":"fixtures.json", + "contracts":[{"surface":"tui","path":"contracts/tui.json"}], + "baselines":[{"metric_id":"metric.tui.render.ns","path":"baselines/metric.tui.render.ns.json"}], + "metric_taxonomy":{"budget_classes":["exact","absolute-budget","regression-budget","hybrid-budget"]}, + "lane_authorities":["required_shared_linux","required_tight_linux","informational_until_stable","contract_pending_dependency","extended"], + "activation_states":["defined","informational","required","contract_pending_dependency"] + }`) +} + +func writeFixtureInventory(t *testing.T, root string) { + t.Helper() + writeFile(t, filepath.Join(root, "fixtures.json"), `{ + "schema_version":"runecode.performance.fixtures.v1", + "fixtures":[ + {"fixture_id":"tui.empty.v1","surface":"tui","runtime_regime":"empty","status":"mvp_reviewed"}, + {"fixture_id":"broker.watch.run.snapshot-follow.v1","surface":"broker","runtime_regime":"watch","status":"mvp_reviewed"}, + {"fixture_id":"anchor.fast-complete.stub.v1","surface":"external-anchor","runtime_regime":"prepare","status":"mvp_reviewed"} + ] + }`) +} + +func writeFixtureContract(t *testing.T, contractsDir string) { + t.Helper() + writeFile(t, filepath.Join(contractsDir, "tui.json"), fixtureContractJSON) +} + +const fixtureContractJSON = `{ + "schema_version":"runecode.performance.contract.v1", + "contract_id":"performance.tui.v1", + "surface":"tui", + "metrics":[ + { + "metric_id":"metric.tui.attach.latency.p95", + "subsystem":"tui", + "runtime_regime":"attach", + "fixture_id":"tui.empty.v1", + "measurement_kind":"latency", + "unit":"ms", + "authoritative_environment":"linux_shared_ci", + "sampling_policy":{"trials":30,"p95_authoritative":true}, + "budget_class":"absolute-budget", + "threshold":{"max_value":500}, + "lane_authority":"required_shared_linux", + "activation_state":"required", + "comparison_method":"p95_ceiling", + "threshold_origin":"product_budget", + "timing_boundary":{"start_event":"tui.process.spawn","end_event":"broker.attach.ready","clock_source":"monotonic","evidence_source":"pty_transcript","included_phases":["launch","attach"]} + }, + { + "metric_id":"metric.broker.watch.latency.p95", + "subsystem":"broker", + "runtime_regime":"watch", + "fixture_id":"broker.watch.run.snapshot-follow.v1", + "measurement_kind":"latency", + "unit":"ms", + "authoritative_environment":"linux_shared_ci", + "sampling_policy":{"trials":30,"p95_authoritative":true}, + "budget_class":"absolute-budget", + "threshold":{"max_value":200}, + "lane_authority":"informational_until_stable", + "activation_state":"informational", + "comparison_method":"p95_ceiling", + "threshold_origin":"first_calibration", + "timing_boundary":{"start_event":"rpc.request_sent","end_event":"watch.snapshot_follow_received","clock_source":"monotonic","evidence_source":"broker_events","included_phases":["watch"]} + }, + { + "metric_id":"metric.anchor.prepare.latency.p95", + "subsystem":"external-anchor", + "runtime_regime":"prepare", + "fixture_id":"anchor.fast-complete.stub.v1", + "measurement_kind":"latency", + "unit":"ms", + "authoritative_environment":"linux_shared_ci", + "sampling_policy":{"trials":30,"p95_authoritative":true}, + "budget_class":"absolute-budget", + "threshold":{"max_value":500}, + "lane_authority":"contract_pending_dependency", + "activation_state":"contract_pending_dependency", + "comparison_method":"p95_ceiling", + "threshold_origin":"temporary_guardrail", + "timing_boundary":{"start_event":"anchor.prepare.begin","end_event":"anchor.prepare.persisted","clock_source":"monotonic","evidence_source":"broker_events","included_phases":["prepare"]} + } + ] + }` + +func writeFixtureBaseline(t *testing.T, baselinesDir string) { + t.Helper() + writeFile(t, filepath.Join(baselinesDir, "metric.tui.render.ns.json"), `{"schema_version":"runecode.performance.baseline.v1","metric_id":"metric.tui.render.ns","unit":"ns/op","samples":[100,101,99],"summary":{"median":100}}`) +} + +func appendRequiredMetric(t *testing.T, root string) { + t.Helper() + contractPath := filepath.Join(root, "contracts", "tui.json") + raw, err := os.ReadFile(contractPath) + if err != nil { + t.Fatalf("ReadFile(%s): %v", contractPath, err) + } + content := strings.TrimSpace(string(raw)) + content = strings.TrimSuffix(content, "}") + content = strings.TrimSpace(content) + content = strings.TrimSuffix(content, "]") + `, + { + "metric_id":"metric.tui.key_response.quiet.p95_ms", + "subsystem":"tui", + "runtime_regime":"key_response_quiet", + "fixture_id":"tui.empty.v1", + "measurement_kind":"latency", + "unit":"ms", + "authoritative_environment":"linux_shared_ci", + "sampling_policy":{"trials":30,"p95_authoritative":true}, + "budget_class":"absolute-budget", + "threshold":{"max_value":50}, + "lane_authority":"required_shared_linux", + "activation_state":"required", + "comparison_method":"p95_ceiling", + "threshold_origin":"product_budget", + "timing_boundary":{"start_event":"tui.key.injected","end_event":"broker.attach.ready.frame_delta","clock_source":"monotonic","evidence_source":"pty_transcript","included_phases":["input_dispatch","render"]} + } + ] + }` + writeFile(t, contractPath, content) +} + +func writeFile(t *testing.T, path string, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile(%s) error: %v", path, err) + } +} diff --git a/tools/perfcontracts/manifest.json b/tools/perfcontracts/manifest.json new file mode 100644 index 00000000..abd88c2b --- /dev/null +++ b/tools/perfcontracts/manifest.json @@ -0,0 +1,63 @@ +{ + "schema_version":"runecode.performance.manifest.v1", + "manifest_version": "1", + "change_ref": "CHG-2026-053-9d2b-performance-baselines-verification-gates-v0", + "fixture_inventory_ref": "fixtures/inventory.v1.json", + "measurement_profiles": [ + "linux_shared_ci", + "linux_pi_reference", + "linux_scaled_reference" + ], + "contracts": [ + {"surface": "tui", "path": "contracts/tui.v1.json"}, + {"surface": "broker", "path": "contracts/broker.v1.json"}, + {"surface": "runner-workflow", "path": "contracts/runner-workflow.v1.json"}, + {"surface": "launcher", "path": "contracts/launcher.v1.json"}, + {"surface": "dependency-audit", "path": "contracts/dependency-audit.v1.json"}, + {"surface": "gateway-secrets", "path": "contracts/gateway-secrets.v1.json"}, + {"surface": "external-anchor", "path": "contracts/external-anchor.v1.json"}, + {"surface": "attestation", "path": "contracts/attestation.v1.json"} + ], + "baselines": [ + {"metric_id": "metric.tui.render.shell_view_waiting.ns_op", "path": "baselines/metric.tui.render.shell_view_waiting.ns_op.v1.json"}, + {"metric_id": "metric.broker.unary.session_list.p95_ms", "path": "baselines/metric.broker.unary.session_list.p95_ms.v1.json"}, + {"metric_id": "metric.runner.boundary_check.wall_ms", "path": "baselines/metric.runner.boundary_check.wall_ms.v1.json"}, + {"metric_id": "metric.runner.protocol_fixtures.wall_ms", "path": "baselines/metric.runner.protocol_fixtures.wall_ms.v1.json"}, + {"metric_id": "metric.workflow.chg049.first_party_beta_slice.wall_ms", "path": "baselines/metric.workflow.chg049.first_party_beta_slice.wall_ms.v1.json"}, + {"metric_id": "metric.workflow.chg050.compile.wall_ms", "path": "baselines/metric.workflow.chg050.compile.wall_ms.v1.json"}, + {"metric_id": "metric.workflow.chg050.validation_canonicalization.wall_ms", "path": "baselines/metric.workflow.chg050.validation_canonicalization.wall_ms.v1.json"}, + {"metric_id": "metric.workflow.chg050.runplan_persist_load.wall_ms", "path": "baselines/metric.workflow.chg050.runplan_persist_load.wall_ms.v1.json"}, + {"metric_id": "metric.deps.cache_miss.small.wall_ms", "path": "baselines/metric.deps.cache_miss.small.wall_ms.v1.json"}, + {"metric_id": "metric.deps.cache_hit.small.wall_ms", "path": "baselines/metric.deps.cache_hit.small.wall_ms.v1.json"}, + {"metric_id": "metric.deps.materialization.workspace_handoff.wall_ms", "path": "baselines/metric.deps.materialization.workspace_handoff.wall_ms.v1.json"}, + {"metric_id": "metric.audit.verify_current_segment.wall_ms", "path": "baselines/metric.audit.verify_current_segment.wall_ms.v1.json"}, + {"metric_id": "metric.audit.finalize_verify.wall_ms", "path": "baselines/metric.audit.finalize_verify.wall_ms.v1.json"} + ], + "metric_taxonomy": { + "budget_classes": [ + "exact", + "absolute-budget", + "regression-budget", + "hybrid-budget" + ] + }, + "lane_authorities": [ + "required_shared_linux", + "required_tight_linux", + "informational_until_stable", + "contract_pending_dependency", + "extended" + ], + "activation_states": [ + "defined", + "informational", + "required", + "contract_pending_dependency" + ], + "deferrals": [ + { + "change_ref": "CHG-2026-061-45fe-performance-program-expansion-cross-platform-gates-v0", + "reason": "broader fixture ladders and cross-platform numeric-gate expansion" + } + ] +} diff --git a/tools/perfgatesharedlinux/main.go b/tools/perfgatesharedlinux/main.go new file mode 100644 index 00000000..07f8ebdb --- /dev/null +++ b/tools/perfgatesharedlinux/main.go @@ -0,0 +1,104 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/runecode-ai/runecode/internal/perfcontracts" + "github.com/runecode-ai/runecode/internal/projectsubstrate" +) + +const checkSchemaVersion = "runecode.performance.check.v1" + +type config struct { + outputPath string + repository string + trials int + timeout time.Duration +} + +type deps struct { + runRunnerWorkflow func(repoRoot string, timeout time.Duration) (perfcontracts.CheckOutput, error) + runBrokerPerf func(repoRoot string, trials int) (perfcontracts.CheckOutput, error) + runPhase5Perf func(repoRoot string, trials int, timeout time.Duration) (perfcontracts.CheckOutput, error) + runTUIQuiet func(repoRoot string, trials int, timeout time.Duration) (perfcontracts.CheckOutput, error) + runTUIWaiting func(repoRoot string, trials int, timeout time.Duration) (perfcontracts.CheckOutput, error) + runTUIBench func(repoRoot string, timeout time.Duration) (perfcontracts.MeasurementRecord, error) + listRequiredIDs func(repoRoot string) ([]string, error) +} + +func main() { + if err := run(os.Args[1:]); err != nil { + var usageErr usageError + if errors.As(err, &usageErr) { + fmt.Fprintf(os.Stderr, "perfgatesharedlinux usage error: %v\n", err) + os.Exit(2) + } + fmt.Fprintf(os.Stderr, "perfgatesharedlinux failed: %v\n", err) + os.Exit(1) + } +} + +func parseArgs(args []string) (config, error) { + fs := flag.NewFlagSet("perfgatesharedlinux", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + output := fs.String("output", "", "output check json path") + repository := fs.String("repository-root", "", "repository root path") + trials := fs.Int("trials", 30, "deterministic broker unary trials") + timeoutMs := fs.Int("timeout-ms", 120000, "runner command timeout milliseconds") + if err := fs.Parse(args); err != nil { + return config{}, usageError{err: err} + } + if strings.TrimSpace(*output) == "" { + return config{}, usageError{err: fmt.Errorf("--output is required")} + } + if *trials <= 0 { + return config{}, usageError{err: fmt.Errorf("--trials must be > 0")} + } + timeout := time.Duration(*timeoutMs) * time.Millisecond + if timeout <= 0 { + return config{}, usageError{err: fmt.Errorf("--timeout-ms must be > 0")} + } + return config{ + outputPath: strings.TrimSpace(*output), + repository: strings.TrimSpace(*repository), + trials: *trials, + timeout: timeout, + }, nil +} + +func resolveRepoRoot(explicit string) (string, error) { + if strings.TrimSpace(explicit) != "" { + return validateRepoRoot(strings.TrimSpace(explicit)) + } + cwd, err := os.Getwd() + if err != nil { + return "", err + } + return validateRepoRoot(cwd) +} + +func validateRepoRoot(root string) (string, error) { + clean := filepath.Clean(root) + if _, err := os.Stat(filepath.Join(clean, "runner", "package.json")); err != nil { + return "", fmt.Errorf("repository root missing runner/package.json: %w", err) + } + if _, err := os.Stat(filepath.Join(clean, "protocol", "schemas")); err != nil { + return "", fmt.Errorf("repository root missing protocol/schemas: %w", err) + } + if _, err := projectsubstrate.DiscoverAndValidate(projectsubstrate.DiscoveryInput{RepositoryRoot: clean, Authority: projectsubstrate.RepoRootAuthorityExplicitConfig}); err != nil { + return "", fmt.Errorf("repository root validation failed: %w", err) + } + return clean, nil +} + +type usageError struct{ err error } + +func (e usageError) Error() string { return e.err.Error() } + +func (e usageError) Unwrap() error { return e.err } diff --git a/tools/perfgatesharedlinux/main_test.go b/tools/perfgatesharedlinux/main_test.go new file mode 100644 index 00000000..a4eed4b9 --- /dev/null +++ b/tools/perfgatesharedlinux/main_test.go @@ -0,0 +1,290 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/runecode-ai/runecode/internal/perfcontracts" +) + +func repositoryRootForPerfGateTests(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) +} + +func TestParseArgsRequiresOutput(t *testing.T) { + _, err := parseArgs([]string{}) + if err == nil { + t.Fatal("parseArgs error = nil, want required output error") + } + var usageErr usageError + if !errors.As(err, &usageErr) { + t.Fatalf("parseArgs error = %T, want usageError", err) + } +} + +func TestRunWithDepsWritesMergedOutput(t *testing.T) { + root := repositoryRootForPerfGateTests(t) + output := filepath.Join(root, "check.json") + cfg := config{outputPath: output, repository: root, trials: 30, timeout: 2 * time.Second} + defer os.Remove(output) + err := runWithDeps(cfg, mergedOutputDeps(t, root)) + if err != nil { + t.Fatalf("runWithDeps error: %v", err) + } + + raw, err := os.ReadFile(output) + if err != nil { + t.Fatalf("ReadFile output: %v", err) + } + var parsed perfcontracts.CheckOutput + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("Unmarshal output: %v", err) + } + if parsed.SchemaVersion != checkSchemaVersion { + t.Fatalf("schema_version = %q, want %q", parsed.SchemaVersion, checkSchemaVersion) + } + if len(parsed.Measurements) != 7 { + t.Fatalf("measurements = %d, want 7", len(parsed.Measurements)) + } +} + +func mergedOutputDeps(t *testing.T, root string) deps { + t.Helper() + return deps{ + runRunnerWorkflow: expectedHarnessOutput(t, root, 2*time.Second, + perfcontracts.MeasurementRecord{MetricID: "metric.runner.boundary_check.wall_ms", Value: 50, Unit: "ms"}, + perfcontracts.MeasurementRecord{MetricID: "metric.runner.protocol_fixtures.wall_ms", Value: 75, Unit: "ms"}, + ), + runBrokerPerf: expectedBrokerOutput(t, root, 30, + perfcontracts.MeasurementRecord{MetricID: "metric.broker.unary.session_list.p95_ms", Value: 1, Unit: "ms"}, + ), + runPhase5Perf: expectedPhase5Output(t, root, 30, 2*time.Second, + perfcontracts.MeasurementRecord{MetricID: "metric.protocol.schema_validation.wall_ms", Value: 10, Unit: "ms"}, + ), + runTUIQuiet: expectedTUIOutput(t, root, 30, 2*time.Second, + perfcontracts.MeasurementRecord{MetricID: "metric.tui.attach.quiet.p95_ms", Value: 20, Unit: "ms"}, + ), + runTUIWaiting: expectedTUIOutput(t, root, 30, 2*time.Second, + perfcontracts.MeasurementRecord{MetricID: "metric.tui.attach.waiting.p95_ms", Value: 22, Unit: "ms"}, + ), + runTUIBench: expectedTUIBench(t, root, 2*time.Second, + perfcontracts.MeasurementRecord{MetricID: "metric.tui.render.shell_view_waiting.ns_op", Value: 1234, Unit: "ns/op"}, + ), + listRequiredIDs: expectedRequiredIDs(t, root, + "metric.runner.boundary_check.wall_ms", + "metric.runner.protocol_fixtures.wall_ms", + "metric.broker.unary.session_list.p95_ms", + "metric.protocol.schema_validation.wall_ms", + "metric.tui.attach.quiet.p95_ms", + "metric.tui.attach.waiting.p95_ms", + "metric.tui.render.shell_view_waiting.ns_op", + ), + } +} + +func expectedRequiredIDs(t *testing.T, root string, metricIDs ...string) func(string) ([]string, error) { + t.Helper() + return func(repoRoot string) ([]string, error) { + if repoRoot != root { + t.Fatalf("repoRoot = %q, want %q", repoRoot, root) + } + return metricIDs, nil + } +} + +func expectedHarnessOutput(t *testing.T, root string, wantTimeout time.Duration, items ...perfcontracts.MeasurementRecord) func(string, time.Duration) (perfcontracts.CheckOutput, error) { + t.Helper() + return func(repoRoot string, timeout time.Duration) (perfcontracts.CheckOutput, error) { + if repoRoot != root { + t.Fatalf("repoRoot = %q, want %q", repoRoot, root) + } + if timeout != wantTimeout { + t.Fatalf("timeout = %s, want %s", timeout, wantTimeout) + } + return perfcontracts.CheckOutput{SchemaVersion: checkSchemaVersion, Measurements: items}, nil + } +} + +func expectedBrokerOutput(t *testing.T, root string, wantTrials int, items ...perfcontracts.MeasurementRecord) func(string, int) (perfcontracts.CheckOutput, error) { + t.Helper() + return func(repoRoot string, trials int) (perfcontracts.CheckOutput, error) { + if repoRoot != root { + t.Fatalf("repoRoot = %q, want %q", repoRoot, root) + } + if trials != wantTrials { + t.Fatalf("trials = %d, want %d", trials, wantTrials) + } + return perfcontracts.CheckOutput{SchemaVersion: checkSchemaVersion, Measurements: items}, nil + } +} + +func expectedPhase5Output(t *testing.T, root string, wantTrials int, wantTimeout time.Duration, items ...perfcontracts.MeasurementRecord) func(string, int, time.Duration) (perfcontracts.CheckOutput, error) { + t.Helper() + return func(repoRoot string, trials int, timeout time.Duration) (perfcontracts.CheckOutput, error) { + if repoRoot != root { + t.Fatalf("repoRoot = %q, want %q", repoRoot, root) + } + if trials != wantTrials { + t.Fatalf("trials = %d, want %d", trials, wantTrials) + } + if timeout != wantTimeout { + t.Fatalf("timeout = %s, want %s", timeout, wantTimeout) + } + return perfcontracts.CheckOutput{SchemaVersion: checkSchemaVersion, Measurements: items}, nil + } +} + +func expectedTUIOutput(t *testing.T, root string, wantTrials int, wantTimeout time.Duration, items ...perfcontracts.MeasurementRecord) func(string, int, time.Duration) (perfcontracts.CheckOutput, error) { + t.Helper() + return func(repoRoot string, trials int, timeout time.Duration) (perfcontracts.CheckOutput, error) { + if repoRoot != root { + t.Fatalf("repoRoot = %q, want %q", repoRoot, root) + } + if trials != wantTrials { + t.Fatalf("trials = %d, want %d", trials, wantTrials) + } + if timeout != wantTimeout { + t.Fatalf("timeout = %s, want %s", timeout, wantTimeout) + } + return perfcontracts.CheckOutput{SchemaVersion: checkSchemaVersion, Measurements: items}, nil + } +} + +func expectedTUIBench(t *testing.T, root string, wantTimeout time.Duration, item perfcontracts.MeasurementRecord) func(string, time.Duration) (perfcontracts.MeasurementRecord, error) { + t.Helper() + return func(repoRoot string, timeout time.Duration) (perfcontracts.MeasurementRecord, error) { + if repoRoot != root { + t.Fatalf("repoRoot = %q, want %q", repoRoot, root) + } + if timeout != wantTimeout { + t.Fatalf("timeout = %s, want %s", timeout, wantTimeout) + } + return item, nil + } +} + +func TestRunWithDepsPropagatesHarnessError(t *testing.T) { + root := repositoryRootForPerfGateTests(t) + cfg := config{outputPath: filepath.Join(root, "check.json"), repository: root, trials: 30, timeout: time.Second} + err := runWithDeps(cfg, deps{ + runRunnerWorkflow: func(_ string, _ time.Duration) (perfcontracts.CheckOutput, error) { + return perfcontracts.CheckOutput{}, assertErr("runner workflow boom") + }, + runBrokerPerf: func(_ string, _ int) (perfcontracts.CheckOutput, error) { + return perfcontracts.CheckOutput{}, nil + }, + runPhase5Perf: func(_ string, _ int, _ time.Duration) (perfcontracts.CheckOutput, error) { + return perfcontracts.CheckOutput{}, nil + }, + runTUIQuiet: func(_ string, _ int, _ time.Duration) (perfcontracts.CheckOutput, error) { + return perfcontracts.CheckOutput{}, nil + }, + runTUIWaiting: func(_ string, _ int, _ time.Duration) (perfcontracts.CheckOutput, error) { + return perfcontracts.CheckOutput{}, nil + }, + runTUIBench: func(_ string, _ time.Duration) (perfcontracts.MeasurementRecord, error) { + return perfcontracts.MeasurementRecord{}, nil + }, + listRequiredIDs: func(_ string) ([]string, error) { return nil, nil }, + }) + if err == nil || !strings.Contains(err.Error(), "run runner workflow perf") { + t.Fatalf("runWithDeps error = %v, want runner workflow context", err) + } +} + +func TestRunWithDepsPropagatesBrokerError(t *testing.T) { + root := repositoryRootForPerfGateTests(t) + cfg := config{outputPath: filepath.Join(root, "check.json"), repository: root, trials: 30, timeout: time.Second} + err := runWithDeps(cfg, deps{ + runRunnerWorkflow: func(_ string, _ time.Duration) (perfcontracts.CheckOutput, error) { + return perfcontracts.CheckOutput{SchemaVersion: checkSchemaVersion, Measurements: []perfcontracts.MeasurementRecord{{MetricID: "metric.runner.boundary_check.wall_ms", Value: 1, Unit: "ms"}}}, nil + }, + runBrokerPerf: func(_ string, _ int) (perfcontracts.CheckOutput, error) { + return perfcontracts.CheckOutput{}, assertErr("broker boom") + }, + runPhase5Perf: func(_ string, _ int, _ time.Duration) (perfcontracts.CheckOutput, error) { + return perfcontracts.CheckOutput{}, nil + }, + runTUIQuiet: func(_ string, _ int, _ time.Duration) (perfcontracts.CheckOutput, error) { + return perfcontracts.CheckOutput{}, nil + }, + runTUIWaiting: func(_ string, _ int, _ time.Duration) (perfcontracts.CheckOutput, error) { + return perfcontracts.CheckOutput{}, nil + }, + runTUIBench: func(_ string, _ time.Duration) (perfcontracts.MeasurementRecord, error) { + return perfcontracts.MeasurementRecord{}, nil + }, + listRequiredIDs: func(_ string) ([]string, error) { return nil, nil }, + }) + if err == nil || !strings.Contains(err.Error(), "run broker perf") { + t.Fatalf("runWithDeps error = %v, want broker context", err) + } +} + +func TestRunWithDepsRejectsInvalidRepositoryRoot(t *testing.T) { + t.Parallel() + cfg := config{outputPath: filepath.Join(t.TempDir(), "check.json"), repository: t.TempDir(), trials: 30, timeout: time.Second} + err := runWithDeps(cfg, deps{}) + if err == nil || !strings.Contains(err.Error(), "repository root") { + t.Fatalf("runWithDeps error = %v, want repository root validation failure", err) + } +} + +func TestRunWithDepsFailsWhenRequiredMetricMissingFromAggregation(t *testing.T) { + root := repositoryRootForPerfGateTests(t) + cfg := config{outputPath: filepath.Join(root, "check.json"), repository: root, trials: 30, timeout: time.Second} + err := runWithDeps(cfg, deps{ + runRunnerWorkflow: expectedHarnessOutput(t, root, time.Second, + perfcontracts.MeasurementRecord{MetricID: "metric.runner.boundary_check.wall_ms", Value: 1, Unit: "ms"}, + ), + runBrokerPerf: expectedBrokerOutput(t, root, 30), + runPhase5Perf: expectedPhase5Output(t, root, 30, time.Second), + runTUIQuiet: expectedTUIOutput(t, root, 30, time.Second), + runTUIWaiting: expectedTUIOutput(t, root, 30, time.Second), + runTUIBench: expectedTUIBench(t, root, time.Second, perfcontracts.MeasurementRecord{MetricID: "metric.tui.render.shell_view_waiting.ns_op", Value: 1, Unit: "ns/op"}), + listRequiredIDs: func(_ string) ([]string, error) { + return []string{"metric.runner.boundary_check.wall_ms", "metric.missing"}, nil + }, + }) + if err == nil || !strings.Contains(err.Error(), "required_shared_linux metrics missing") { + t.Fatalf("runWithDeps error = %v, want missing required metric error", err) + } +} + +func TestParseBenchmarkMedianNSOp(t *testing.T) { + out := strings.Join([]string{ + "BenchmarkShellViewWaitingSession-8 12493 8912 ns/op 1200 B/op 10 allocs/op", + "BenchmarkShellViewWaitingSession-8 12493 9012 ns/op 1200 B/op 10 allocs/op", + "BenchmarkShellViewWaitingSession-8 12493 8812 ns/op 1200 B/op 10 allocs/op", + }, "\n") + "\n" + value, err := parseBenchmarkMedianNSOp(out, "BenchmarkShellViewWaitingSession", 3) + if err != nil { + t.Fatalf("parseBenchmarkMedianNSOp returned error: %v", err) + } + if value != 8912 { + t.Fatalf("value = %v, want 8912", value) + } +} + +func TestParseBenchmarkMedianNSOpRejectsWrongSampleCount(t *testing.T) { + out := "BenchmarkShellViewWaitingSession-8 12493 8912 ns/op 1200 B/op 10 allocs/op\n" + _, err := parseBenchmarkMedianNSOp(out, "BenchmarkShellViewWaitingSession", 2) + if err == nil || !strings.Contains(err.Error(), "sample count") { + t.Fatalf("parseBenchmarkMedianNSOp error = %v, want sample count failure", err) + } +} + +type assertErr string + +func (e assertErr) Error() string { return string(e) } diff --git a/tools/perfgatesharedlinux/required.go b/tools/perfgatesharedlinux/required.go new file mode 100644 index 00000000..0d723fc5 --- /dev/null +++ b/tools/perfgatesharedlinux/required.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/runecode-ai/runecode/internal/perfcontracts" +) + +func requiredSharedLinuxMetricIDs(repoRoot string) ([]string, error) { + root := filepath.Join(repoRoot, "tools", "perfcontracts") + manifest, err := perfcontracts.LoadManifest(root) + if err != nil { + return nil, err + } + required, err := requiredMetricSet(root, manifest.Contracts) + if err != nil { + return nil, err + } + return sortedMetricIDs(required), nil +} + +func requiredMetricSet(root string, entries []perfcontracts.ManifestContract) (map[string]struct{}, error) { + required := map[string]struct{}{} + for _, entry := range entries { + contract, err := perfcontracts.LoadContract(root, entry.Path) + if err != nil { + return nil, err + } + collectRequiredMetrics(required, contract.Metrics) + } + return required, nil +} + +func collectRequiredMetrics(required map[string]struct{}, metrics []perfcontracts.MetricContract) { + for _, metric := range metrics { + if metric.LaneAuthority == "required_shared_linux" && metric.ActivationState == "required" { + required[metric.MetricID] = struct{}{} + } + } +} + +func sortedMetricIDs(required map[string]struct{}) []string { + out := make([]string, 0, len(required)) + for metricID := range required { + out = append(out, metricID) + } + sort.Strings(out) + return out +} + +func selectRequiredMeasurements(measurements []perfcontracts.MeasurementRecord, requiredIDs []string) ([]perfcontracts.MeasurementRecord, error) { + byMetric := map[string]perfcontracts.MeasurementRecord{} + for _, measurement := range measurements { + byMetric[measurement.MetricID] = measurement + } + selected := make([]perfcontracts.MeasurementRecord, 0, len(requiredIDs)) + missing := make([]string, 0) + for _, metricID := range requiredIDs { + measurement, ok := byMetric[metricID] + if !ok { + missing = append(missing, metricID) + continue + } + selected = append(selected, measurement) + } + if len(missing) > 0 { + return nil, fmt.Errorf("required_shared_linux metrics missing from aggregated output: %s", strings.Join(missing, ", ")) + } + return selected, nil +} diff --git a/tools/perfgatesharedlinux/run.go b/tools/perfgatesharedlinux/run.go new file mode 100644 index 00000000..aaa41093 --- /dev/null +++ b/tools/perfgatesharedlinux/run.go @@ -0,0 +1,103 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "time" + + "github.com/runecode-ai/runecode/internal/brokerapi" + "github.com/runecode-ai/runecode/internal/brokerperf" + "github.com/runecode-ai/runecode/internal/perfcontracts" + "github.com/runecode-ai/runecode/internal/runnerworkflowperf" +) + +func run(args []string) error { + cfg, err := parseArgs(args) + if err != nil { + return err + } + return runWithDeps(cfg, deps{ + runRunnerWorkflow: func(repoRoot string, timeout time.Duration) (perfcontracts.CheckOutput, error) { + return runnerworkflowperf.Run(runnerworkflowperf.HarnessConfig{RepositoryRoot: repoRoot, CommandTimeout: timeout}) + }, + runBrokerPerf: func(repoRoot string, trials int) (perfcontracts.CheckOutput, error) { + return brokerperf.Run(brokerperf.HarnessConfig{RepositoryRoot: repoRoot, Trials: trials}) + }, + runPhase5Perf: func(repoRoot string, trials int, timeout time.Duration) (perfcontracts.CheckOutput, error) { + return brokerapi.RunPhase5PerformanceHarness(brokerapi.Phase5PerformanceHarnessConfig{RepositoryRoot: repoRoot, Trials: trials, CommandTimeout: timeout}) + }, + runTUIQuiet: func(repoRoot string, trials int, timeout time.Duration) (perfcontracts.CheckOutput, error) { + return measureTUILatency(repoRoot, "tui.empty.v1", trials, timeout) + }, + runTUIWaiting: func(repoRoot string, trials int, timeout time.Duration) (perfcontracts.CheckOutput, error) { + return measureTUILatency(repoRoot, "tui.waiting.v1", trials, timeout) + }, + runTUIBench: measureTUIRenderWaitingBenchmark, + listRequiredIDs: requiredSharedLinuxMetricIDs, + }) +} + +func runWithDeps(cfg config, d deps) error { + repoRoot, err := resolveRepoRoot(cfg.repository) + if err != nil { + return err + } + measurements, err := collectMeasurements(cfg, repoRoot, d) + if err != nil { + return err + } + requiredIDs, err := d.listRequiredIDs(repoRoot) + if err != nil { + return err + } + measurements, err = selectRequiredMeasurements(measurements, requiredIDs) + if err != nil { + return err + } + raw, err := json.MarshalIndent(perfcontracts.CheckOutput{SchemaVersion: checkSchemaVersion, Measurements: measurements}, "", " ") + if err != nil { + return err + } + return os.WriteFile(cfg.outputPath, raw, 0o644) +} + +func collectMeasurements(cfg config, repoRoot string, d deps) ([]perfcontracts.MeasurementRecord, error) { + runnerWorkflow, err := d.runRunnerWorkflow(repoRoot, cfg.timeout) + if err != nil { + return nil, fmt.Errorf("run runner workflow perf: %w", err) + } + brokerOut, err := d.runBrokerPerf(repoRoot, cfg.trials) + if err != nil { + return nil, fmt.Errorf("run broker perf: %w", err) + } + phase5Out, err := d.runPhase5Perf(repoRoot, cfg.trials, cfg.timeout) + if err != nil { + return nil, fmt.Errorf("run phase5 perf: %w", err) + } + tuiQuietOut, err := d.runTUIQuiet(repoRoot, cfg.trials, cfg.timeout) + if err != nil { + return nil, fmt.Errorf("run tui latency (quiet) perf: %w", err) + } + tuiWaitingOut, err := d.runTUIWaiting(repoRoot, cfg.trials, cfg.timeout) + if err != nil { + return nil, fmt.Errorf("run tui latency (waiting) perf: %w", err) + } + tuiBench, err := d.runTUIBench(repoRoot, cfg.timeout) + if err != nil { + return nil, fmt.Errorf("run tui benchmark perf: %w", err) + } + return mergedMeasurements(tuiBench, runnerWorkflow, brokerOut, phase5Out, tuiQuietOut, tuiWaitingOut), nil +} + +func mergedMeasurements(tuiBench perfcontracts.MeasurementRecord, outputs ...perfcontracts.CheckOutput) []perfcontracts.MeasurementRecord { + total := 1 + for _, output := range outputs { + total += len(output.Measurements) + } + measurements := make([]perfcontracts.MeasurementRecord, 0, total) + for _, output := range outputs { + measurements = append(measurements, output.Measurements...) + } + return append(measurements, tuiBench) +} diff --git a/tools/perfgatesharedlinux/tui.go b/tools/perfgatesharedlinux/tui.go new file mode 100644 index 00000000..39477169 --- /dev/null +++ b/tools/perfgatesharedlinux/tui.go @@ -0,0 +1,125 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/runecode-ai/runecode/internal/perfcontracts" +) + +const tuiRenderBenchmarkSamples = 10 + +func measureTUILatency(repoRoot, fixtureID string, trials int, timeout time.Duration) (perfcontracts.CheckOutput, error) { + tmpDir, err := os.MkdirTemp("", "runecode-perfgate-tui-latency-") + if err != nil { + return perfcontracts.CheckOutput{}, err + } + defer os.RemoveAll(tmpDir) + outputPath := filepath.Join(tmpDir, "latency.json") + ctx, cancel := context.WithTimeout(context.Background(), latencyBatchTimeout(timeout, trials)) + defer cancel() + cmd := exec.CommandContext(ctx, + "go", "run", "./tools/tuiperf", + "--mode", "latency", + "--output", outputPath, + "--fixture-id", fixtureID, + "--runtime-dir", filepath.Join(tmpDir, "runtime"), + "--socket-name", "runecode.sock", + "--state-root", filepath.Join(tmpDir, "state"), + "--audit-ledger-root", filepath.Join(tmpDir, "audit-ledger"), + "--target-alias", "default", + "--trials", strconv.Itoa(trials), + "--timeout-ms", strconv.Itoa(int(timeout.Milliseconds())), + ) + cmd.Dir = repoRoot + cmd.Stdout = io.Discard + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return perfcontracts.CheckOutput{}, commandFailure(err, stderr.String(), fmt.Sprintf("tuiperf latency %s", fixtureID)) + } + return perfcontracts.LoadCheckOutput(outputPath) +} + +func latencyBatchTimeout(perTrialTimeout time.Duration, trials int) time.Duration { + if trials < 1 { + trials = 1 + } + estimated := 30*time.Second + time.Duration(trials)*7*time.Second + if estimated > perTrialTimeout { + return estimated + } + return perTrialTimeout +} + +func measureTUIRenderWaitingBenchmark(repoRoot string, timeout time.Duration) (perfcontracts.MeasurementRecord, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "test", "./cmd/runecode-tui", "-run", "^$", "-bench", "BenchmarkShellViewWaitingSession$", "-benchmem", "-count", strconv.Itoa(tuiRenderBenchmarkSamples)) + cmd.Dir = repoRoot + out, err := cmd.CombinedOutput() + if err != nil { + return perfcontracts.MeasurementRecord{}, commandFailure(err, string(out), "run bench BenchmarkShellViewWaitingSession") + } + value, err := parseBenchmarkMedianNSOp(string(out), "BenchmarkShellViewWaitingSession", tuiRenderBenchmarkSamples) + if err != nil { + return perfcontracts.MeasurementRecord{}, err + } + return perfcontracts.MeasurementRecord{MetricID: "metric.tui.render.shell_view_waiting.ns_op", Value: value, Unit: "ns/op"}, nil +} + +func commandFailure(err error, output, label string) error { + msg := strings.TrimSpace(output) + if msg == "" { + msg = err.Error() + } + return fmt.Errorf("%s failed: %s", label, msg) +} + +func parseBenchmarkMedianNSOp(output, benchmark string, wantSamples int) (float64, error) { + values, err := parseBenchmarkNSOps(output, benchmark) + if err != nil { + return 0, err + } + if wantSamples > 0 && len(values) != wantSamples { + return 0, fmt.Errorf("benchmark %s ns/op sample count = %d, want %d", benchmark, len(values), wantSamples) + } + return medianFloat64(values), nil +} + +func parseBenchmarkNSOps(output, benchmark string) ([]float64, error) { + pattern := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(benchmark) + `-\d+\s+\d+\s+([0-9]+(?:\.[0-9]+)?)\s+ns/op`) + matches := pattern.FindAllStringSubmatch(output, -1) + if len(matches) == 0 { + return nil, fmt.Errorf("benchmark %s ns/op missing from output", benchmark) + } + values := make([]float64, 0, len(matches)) + for _, match := range matches { + value, err := strconv.ParseFloat(match[1], 64) + if err != nil { + return nil, fmt.Errorf("parse benchmark %s ns/op: %w", benchmark, err) + } + values = append(values, value) + } + return values, nil +} + +func medianFloat64(values []float64) float64 { + cp := append([]float64(nil), values...) + sort.Float64s(cp) + mid := len(cp) / 2 + if len(cp)%2 == 0 { + return (cp[mid-1] + cp[mid]) / 2 + } + return cp[mid] +} diff --git a/tools/perfseedwait/main.go b/tools/perfseedwait/main.go index 37ab68d5..76f6fbd4 100644 --- a/tools/perfseedwait/main.go +++ b/tools/perfseedwait/main.go @@ -9,16 +9,23 @@ import ( "time" "github.com/runecode-ai/runecode/internal/brokerapi" + "github.com/runecode-ai/runecode/internal/perffixtures" ) type config struct { runtimeDir string socketName string sessionID string + storeRoot string + fixtureID string } func main() { cfg := parseConfig() + if cfg.fixtureID != "" { + seedDeterministicStoreFixture(cfg) + return + } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() client, err := brokerapi.DialLocalRPC(ctx, brokerapi.LocalIPCConfig{RuntimeDir: cfg.runtimeDir, SocketName: cfg.socketName}) @@ -36,7 +43,16 @@ func parseConfig() config { runtimeDir := flag.String("runtime-dir", "", "runtime directory") socketName := flag.String("socket-name", "perf.sock", "socket name") sessionID := flag.String("session-id", "", "session id") + storeRoot := flag.String("store-root", "", "store root for deterministic fixture mode") + fixtureID := flag.String("fixture-id", "", "deterministic fixture id (tui.empty.v1 or tui.waiting.v1)") flag.Parse() + if *fixtureID != "" { + if *storeRoot == "" { + fmt.Fprintln(os.Stderr, "--store-root is required when --fixture-id is provided") + os.Exit(2) + } + return config{storeRoot: *storeRoot, fixtureID: *fixtureID} + } if *runtimeDir == "" { fmt.Fprintln(os.Stderr, "--runtime-dir is required") os.Exit(2) @@ -48,6 +64,20 @@ func parseConfig() config { return config{runtimeDir: *runtimeDir, socketName: *socketName, sessionID: *sessionID} } +func seedDeterministicStoreFixture(cfg config) { + result, err := perffixtures.BuildBrokerStoreFixture(cfg.storeRoot, cfg.fixtureID) + if err != nil { + fmt.Fprintf(os.Stderr, "build deterministic fixture: %v\n", err) + os.Exit(1) + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(result); err != nil { + fmt.Fprintf(os.Stderr, "encode fixture result: %v\n", err) + os.Exit(1) + } +} + func seedWaitSession(ctx context.Context, client *brokerapi.LocalRPCClient, cfg config) { for i, msg := range []string{"first", "second"} { resp := brokerapi.SessionExecutionTriggerResponse{} @@ -82,9 +112,18 @@ func printSession(ctx context.Context, client *brokerapi.LocalRPCClient, session os.Exit(1) } + summary := struct { + SessionID string `json:"session_id"` + Status string `json:"status"` + WorkState string `json:"work_posture"` + }{ + SessionID: result.Session.Summary.Identity.SessionID, + Status: result.Session.Summary.Status, + WorkState: result.Session.Summary.WorkPosture, + } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") - if err := enc.Encode(result.Session); err != nil { + if err := enc.Encode(summary); err != nil { fmt.Fprintf(os.Stderr, "encode result: %v\n", err) os.Exit(1) } diff --git a/tools/phase5perf/main.go b/tools/phase5perf/main.go new file mode 100644 index 00000000..b5979fd9 --- /dev/null +++ b/tools/phase5perf/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/runecode-ai/runecode/internal/brokerapi" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + var usageErr usageError + if errors.As(err, &usageErr) { + fmt.Fprintf(os.Stderr, "phase5perf usage error: %v\n", err) + os.Exit(2) + } + fmt.Fprintf(os.Stderr, "phase5perf failed: %v\n", err) + os.Exit(1) + } +} + +func run(args []string) error { + fs := flag.NewFlagSet("phase5perf", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + output := fs.String("output", "", "output check json path") + repositoryRoot := fs.String("repository-root", "", "repository root") + trials := fs.Int("trials", 10, "deterministic trial count for p95 metrics") + timeoutMS := fs.Int("timeout-ms", 120000, "per-command timeout milliseconds") + if err := fs.Parse(args); err != nil { + return usageError{err: err} + } + if strings.TrimSpace(*output) == "" { + return usageError{err: fmt.Errorf("--output is required")} + } + out, err := brokerapi.RunPhase5PerformanceHarness(brokerapi.Phase5PerformanceHarnessConfig{ + RepositoryRoot: strings.TrimSpace(*repositoryRoot), + Trials: *trials, + CommandTimeout: time.Duration(*timeoutMS) * time.Millisecond, + }) + if err != nil { + return err + } + raw, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + return os.WriteFile(strings.TrimSpace(*output), raw, 0o644) +} + +type usageError struct{ err error } + +func (e usageError) Error() string { return e.err.Error() } + +func (e usageError) Unwrap() error { return e.err } diff --git a/tools/phase5perf/main_test.go b/tools/phase5perf/main_test.go new file mode 100644 index 00000000..1e2b615f --- /dev/null +++ b/tools/phase5perf/main_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "errors" + "testing" +) + +func TestRunReturnsUsageErrorWhenOutputMissing(t *testing.T) { + err := run([]string{}) + if err == nil { + t.Fatal("run error = nil, want usage error") + } + var usageErr usageError + if !errors.As(err, &usageErr) { + t.Fatalf("run error = %T, want usageError", err) + } +} + +func TestRunReturnsUsageErrorForInvalidFlag(t *testing.T) { + err := run([]string{"--bad-flag"}) + if err == nil { + t.Fatal("run error = nil, want usage error") + } + var usageErr usageError + if !errors.As(err, &usageErr) { + t.Fatalf("run error = %T, want usageError", err) + } +} diff --git a/tools/runnerworkflowperf/main.go b/tools/runnerworkflowperf/main.go new file mode 100644 index 00000000..3158c94d --- /dev/null +++ b/tools/runnerworkflowperf/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/runecode-ai/runecode/internal/runnerworkflowperf" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + var usageErr usageError + if errors.As(err, &usageErr) { + fmt.Fprintf(os.Stderr, "runnerworkflowperf usage error: %v\n", err) + os.Exit(2) + } + fmt.Fprintf(os.Stderr, "runnerworkflowperf failed: %v\n", err) + os.Exit(1) + } +} + +func run(args []string) error { + fs := flag.NewFlagSet("runnerworkflowperf", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + output := fs.String("output", "", "output check json path") + repositoryRoot := fs.String("repository-root", "", "repository root") + timeoutMs := fs.Int("timeout-ms", 120000, "per-command timeout milliseconds") + if err := fs.Parse(args); err != nil { + return usageError{err: err} + } + if strings.TrimSpace(*output) == "" { + return usageError{err: fmt.Errorf("--output is required")} + } + if strings.TrimSpace(*repositoryRoot) == "" { + return usageError{err: fmt.Errorf("--repository-root is required")} + } + out, err := runnerworkflowperf.Run(runnerworkflowperf.HarnessConfig{ + RepositoryRoot: strings.TrimSpace(*repositoryRoot), + CommandTimeout: time.Duration(*timeoutMs) * time.Millisecond, + }) + if err != nil { + return err + } + raw, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + return os.WriteFile(strings.TrimSpace(*output), raw, 0o644) +} + +type usageError struct{ err error } + +func (e usageError) Error() string { return e.err.Error() } + +func (e usageError) Unwrap() error { return e.err } diff --git a/tools/runnerworkflowperf/main_test.go b/tools/runnerworkflowperf/main_test.go new file mode 100644 index 00000000..1e2b615f --- /dev/null +++ b/tools/runnerworkflowperf/main_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "errors" + "testing" +) + +func TestRunReturnsUsageErrorWhenOutputMissing(t *testing.T) { + err := run([]string{}) + if err == nil { + t.Fatal("run error = nil, want usage error") + } + var usageErr usageError + if !errors.As(err, &usageErr) { + t.Fatalf("run error = %T, want usageError", err) + } +} + +func TestRunReturnsUsageErrorForInvalidFlag(t *testing.T) { + err := run([]string{"--bad-flag"}) + if err == nil { + t.Fatal("run error = nil, want usage error") + } + var usageErr usageError + if !errors.As(err, &usageErr) { + t.Fatalf("run error = %T, want usageError", err) + } +} diff --git a/tools/tlccheck/fs.go b/tools/tlccheck/fs.go new file mode 100644 index 00000000..967ea606 --- /dev/null +++ b/tools/tlccheck/fs.go @@ -0,0 +1,81 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" +) + +func resolveRepoRoot() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("resolve current directory: %w", err) + } + root, ok := findRepoRoot(cwd) + if !ok { + return "", fmt.Errorf("resolve repo root from %s: required markers go.mod, justfile, and %s", cwd, specDirRelative) + } + return root, nil +} + +func findRepoRoot(start string) (string, bool) { + dir := start + for { + if looksLikeRepoRoot(dir) { + return dir, true + } + parent := filepath.Dir(dir) + if parent == dir { + return "", false + } + dir = parent + } +} + +func looksLikeRepoRoot(path string) bool { + if !fileExists(filepath.Join(path, "go.mod")) { + return false + } + if !fileExists(filepath.Join(path, "justfile")) { + return false + } + return dirExists(filepath.Join(path, specDirRelative)) +} + +func ensureDir(path string) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("stat dir %s: %w", path, err) + } + if !info.IsDir() { + return fmt.Errorf("expected directory: %s", path) + } + return nil +} + +func ensureFile(path string) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("stat file %s: %w", path, err) + } + if info.IsDir() { + return fmt.Errorf("expected file, got directory: %s", path) + } + return nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return !info.IsDir() +} + +func dirExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.IsDir() +} diff --git a/tools/tlccheck/main.go b/tools/tlccheck/main.go index ce5ccad1..ea00b139 100644 --- a/tools/tlccheck/main.go +++ b/tools/tlccheck/main.go @@ -3,6 +3,7 @@ package main import ( "errors" + "flag" "fmt" "os" "os/exec" @@ -29,13 +30,28 @@ var ( ) func main() { - if err := run(); err != nil { + if err := run(os.Args[1:]); err != nil { + var usageErr usageError + if errors.As(err, &usageErr) { + fmt.Fprintf(os.Stderr, "tlccheck usage error: %v\n", err) + os.Exit(2) + } fmt.Fprintf(os.Stderr, "tlc model check failed: %v\n", err) os.Exit(1) } } -func run() error { +func run(args []string) error { + fs := flag.NewFlagSet("tlccheck", flag.ContinueOnError) + mode := fs.String("mode", "all", "model-check mode: all, core, or replay") + if err := fs.Parse(args); err != nil { + return usageError{err: err} + } + configs, err := selectedModelConfigs(*mode) + if err != nil { + return err + } + repoRoot, err := resolveRepoRoot() if err != nil { return err @@ -51,7 +67,7 @@ func run() error { return err } - for _, cfg := range modelConfigs { + for _, cfg := range configs { cfgPath := filepath.Join(specDir, cfg) if err := ensureFile(cfgPath); err != nil { return err @@ -199,82 +215,8 @@ func nixTLCRunner(repoRoot, nixPath string) tlcRunner { } } -func resolveRepoRoot() (string, error) { - cwd, err := os.Getwd() - if err != nil { - return "", fmt.Errorf("resolve current directory: %w", err) - } - - root, ok := findRepoRoot(cwd) - if !ok { - return "", fmt.Errorf("resolve repo root from %s: required markers go.mod, justfile, and %s", cwd, specDirRelative) - } - - return root, nil -} - -func findRepoRoot(start string) (string, bool) { - dir := start - for { - if looksLikeRepoRoot(dir) { - return dir, true - } - parent := filepath.Dir(dir) - if parent == dir { - return "", false - } - dir = parent - } -} +type usageError struct{ err error } -func looksLikeRepoRoot(path string) bool { - if !fileExists(filepath.Join(path, "go.mod")) { - return false - } - if !fileExists(filepath.Join(path, "justfile")) { - return false - } - return dirExists(filepath.Join(path, specDirRelative)) -} +func (e usageError) Error() string { return e.err.Error() } -func ensureDir(path string) error { - info, err := os.Stat(path) - if err != nil { - return fmt.Errorf("stat dir %s: %w", path, err) - } - if !info.IsDir() { - return fmt.Errorf("expected directory: %s", path) - } - - return nil -} - -func ensureFile(path string) error { - info, err := os.Stat(path) - if err != nil { - return fmt.Errorf("stat file %s: %w", path, err) - } - if info.IsDir() { - return fmt.Errorf("expected file, got directory: %s", path) - } - - return nil -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - if err != nil { - return false - } - - return !info.IsDir() -} - -func dirExists(path string) bool { - info, err := os.Stat(path) - if err != nil { - return false - } - - return info.IsDir() -} +func (e usageError) Unwrap() error { return e.err } diff --git a/tools/tlccheck/main_test.go b/tools/tlccheck/main_test.go index 0a3424bf..2049da26 100644 --- a/tools/tlccheck/main_test.go +++ b/tools/tlccheck/main_test.go @@ -146,6 +146,37 @@ func TestFindRepoRootWalksUpToRepoMarkers(t *testing.T) { } } +func TestSelectedModelConfigs(t *testing.T) { + tests := []struct { + mode string + want []string + }{ + {mode: "all", want: []string{"SecurityKernelV0.core.cfg", "SecurityKernelV0.replay.cfg"}}, + {mode: "core", want: []string{"SecurityKernelV0.core.cfg"}}, + {mode: "replay", want: []string{"SecurityKernelV0.replay.cfg"}}, + } + for _, test := range tests { + got, err := selectedModelConfigs(test.mode) + if err != nil { + t.Fatalf("selectedModelConfigs(%q) error = %v, want nil", test.mode, err) + } + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("selectedModelConfigs(%q) = %#v, want %#v", test.mode, got, test.want) + } + } +} + +func TestSelectedModelConfigsRejectsUnknownMode(t *testing.T) { + _, err := selectedModelConfigs("unknown") + if err == nil { + t.Fatal("selectedModelConfigs error = nil, want unsupported mode failure") + } + var usageErr usageError + if !errors.As(err, &usageErr) { + t.Fatalf("selectedModelConfigs error = %T, want usageError", err) + } +} + func lookPathStub(entries map[string]string) func(string) (string, error) { return func(file string) (string, error) { if path, ok := entries[file]; ok { diff --git a/tools/tlccheck/modes.go b/tools/tlccheck/modes.go new file mode 100644 index 00000000..8982039c --- /dev/null +++ b/tools/tlccheck/modes.go @@ -0,0 +1,19 @@ +package main + +import ( + "fmt" + "strings" +) + +func selectedModelConfigs(mode string) ([]string, error) { + switch strings.TrimSpace(mode) { + case "", "all": + return modelConfigs, nil + case "core": + return []string{"SecurityKernelV0.core.cfg"}, nil + case "replay": + return []string{"SecurityKernelV0.replay.cfg"}, nil + default: + return nil, usageError{err: fmt.Errorf("unsupported mode %q", mode)} + } +} diff --git a/tools/tuiperf/args.go b/tools/tuiperf/args.go new file mode 100644 index 00000000..f5eccfe3 --- /dev/null +++ b/tools/tuiperf/args.go @@ -0,0 +1,111 @@ +//go:build linux + +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +func run(args []string) error { + cfg, err := parseArgs(args) + if err != nil { + return err + } + return runMode(cfg) +} + +func runMode(cfg config) error { + switch cfg.mode { + case "cpu": + return runCPUMode(cfg) + case "latency": + return runLatencyMode(cfg) + case "bench-parse": + return runBenchParseMode(cfg) + default: + return usageError{err: fmt.Errorf("unsupported mode %q", cfg.mode)} + } +} + +func parseArgs(args []string) (config, error) { + fs := flag.NewFlagSet("tuiperf", flag.ContinueOnError) + fs.SetOutput(io.Discard) + mode := fs.String("mode", "", "cpu|latency|bench-parse") + output := fs.String("output", "", "output check json path") + fixtureID := fs.String("fixture-id", "", "tui.empty.v1|tui.waiting.v1") + runtimeDir := fs.String("runtime-dir", "", "isolated runtime dir") + socketName := fs.String("socket-name", "", "isolated socket name") + stateRoot := fs.String("state-root", "", "isolated broker state root") + auditLedgerRoot := fs.String("audit-ledger-root", "", "isolated broker audit ledger root") + targetAlias := fs.String("target-alias", "", "RUNECODE_TUI_BROKER_TARGET alias") + trials := fs.Int("trials", 30, "latency trials") + warmupMs := fs.Int("warmup-ms", 3000, "cpu warmup millis") + windowMs := fs.Int("window-ms", 20000, "cpu observation window millis") + windows := fs.Int("windows", 3, "cpu observation windows") + timeoutMs := fs.Int("timeout-ms", 120000, "mode timeout millis") + benchOutput := fs.String("bench-output", "", "go test bench output path for bench-parse mode") + if err := fs.Parse(args); err != nil { + return config{}, usageError{err: err} + } + return buildConfig(mode, output, fixtureID, runtimeDir, socketName, stateRoot, auditLedgerRoot, targetAlias, trials, warmupMs, windowMs, windows, timeoutMs, benchOutput) +} + +func buildConfig( + mode *string, + output *string, + fixtureID *string, + runtimeDir *string, + socketName *string, + stateRoot *string, + auditLedgerRoot *string, + targetAlias *string, + trials *int, + warmupMs *int, + windowMs *int, + windows *int, + timeoutMs *int, + benchOutput *string, +) (config, error) { + if strings.TrimSpace(*mode) == "" || strings.TrimSpace(*output) == "" { + return config{}, usageError{err: errors.New("--mode and --output are required")} + } + timeout := time.Duration(*timeoutMs) * time.Millisecond + if timeout <= 0 { + timeout = 120 * time.Second + } + wd, err := os.Getwd() + if err != nil { + return config{}, err + } + repoRoot := filepath.Clean(wd) + return config{ + mode: strings.TrimSpace(*mode), + outputPath: strings.TrimSpace(*output), + fixtureID: strings.TrimSpace(*fixtureID), + runtimeDir: strings.TrimSpace(*runtimeDir), + socketName: strings.TrimSpace(*socketName), + stateRoot: strings.TrimSpace(*stateRoot), + auditLedgerRoot: strings.TrimSpace(*auditLedgerRoot), + targetAlias: strings.TrimSpace(*targetAlias), + repoRoot: repoRoot, + trials: *trials, + warmup: time.Duration(*warmupMs) * time.Millisecond, + window: time.Duration(*windowMs) * time.Millisecond, + windows: *windows, + timeout: timeout, + benchOutput: strings.TrimSpace(*benchOutput), + }, nil +} + +type usageError struct{ err error } + +func (e usageError) Error() string { return e.err.Error() } + +func (e usageError) Unwrap() error { return e.err } diff --git a/tools/tuiperf/args_test.go b/tools/tuiperf/args_test.go new file mode 100644 index 00000000..5b0ca7e6 --- /dev/null +++ b/tools/tuiperf/args_test.go @@ -0,0 +1,30 @@ +//go:build linux + +package main + +import ( + "errors" + "testing" +) + +func TestParseArgsReturnsUsageErrorWhenRequiredFlagsMissing(t *testing.T) { + _, err := parseArgs([]string{}) + if err == nil { + t.Fatal("parseArgs error = nil, want usage error") + } + var usageErr usageError + if !errors.As(err, &usageErr) { + t.Fatalf("parseArgs error = %T, want usageError", err) + } +} + +func TestParseArgsReturnsUsageErrorForInvalidFlag(t *testing.T) { + _, err := parseArgs([]string{"--bad-flag"}) + if err == nil { + t.Fatal("parseArgs error = nil, want usage error") + } + var usageErr usageError + if !errors.As(err, &usageErr) { + t.Fatalf("parseArgs error = %T, want usageError", err) + } +} diff --git a/tools/tuiperf/harness.go b/tools/tuiperf/harness.go new file mode 100644 index 00000000..974187de --- /dev/null +++ b/tools/tuiperf/harness.go @@ -0,0 +1,261 @@ +//go:build linux + +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "syscall" + "time" + + "github.com/creack/pty" +) + +const terminateGracePeriod = 2 * time.Second + +var ( + absPathPattern = regexp.MustCompile(`(?:[A-Za-z]:\\|/)[^\s"']+`) + longTokenPattern = regexp.MustCompile(`\b[A-Za-z0-9_=-]{24,}\b`) + wsPattern = regexp.MustCompile(`\s+`) + nonPrintablePattern = regexp.MustCompile(`[^[:print:]\t\n\r]`) +) + +type runningHarness struct { + ctx context.Context + cancel context.CancelFunc + brokerCmd *exec.Cmd + tuiCmd *exec.Cmd + tuiOut io.ReadCloser + tuiIn io.WriteCloser +} + +func startTUIHarness(cfg config) (context.Context, context.CancelFunc, runningHarness, error) { + if err := requireTUIFixtureConfig(cfg); err != nil { + return nil, nil, runningHarness{}, err + } + ctx, cancel := context.WithTimeout(context.Background(), cfg.timeout) + preparedCfg, err := prepareHarnessBinaries(cfg) + if err != nil { + cancel() + return nil, nil, runningHarness{}, err + } + cancelWithCleanup := func() { + cancel() + cleanupHarnessBinaries(preparedCfg) + } + if err := prepareTUIIsolation(cfg); err != nil { + cancelWithCleanup() + return nil, nil, runningHarness{}, err + } + harness, err := startHarnessProcesses(ctx, preparedCfg) + if err != nil { + cancelWithCleanup() + return nil, nil, runningHarness{}, err + } + return ctx, cancelWithCleanup, harness, nil +} + +func prepareHarnessBinaries(cfg config) (config, error) { + if cfg.repoRoot == "" { + return cfg, fmt.Errorf("repository root required") + } + binDir, err := os.MkdirTemp("", "runecode-tuiperf-bin-") + if err != nil { + return cfg, err + } + brokerBin := filepath.Join(binDir, "runecode-broker") + tuiBin := filepath.Join(binDir, "runecode-tui") + cfg.harnessBinDir = binDir + if err := buildHarnessBinary(cfg.repoRoot, "./cmd/runecode-broker", brokerBin); err != nil { + _ = os.RemoveAll(binDir) + return cfg, err + } + if err := buildHarnessBinary(cfg.repoRoot, "./cmd/runecode-tui", tuiBin); err != nil { + _ = os.RemoveAll(binDir) + return cfg, err + } + cfg.brokerBin = brokerBin + cfg.tuiBin = tuiBin + return cfg, nil +} + +func cleanupHarnessBinaries(cfg config) { + if cfg.harnessBinDir != "" { + _ = os.RemoveAll(cfg.harnessBinDir) + } +} + +func buildHarnessBinary(repoRoot, pkg, output string) error { + cmd := exec.Command("go", "build", "-o", output, pkg) + cmd.Dir = repoRoot + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("build %s: %s", pkg, strings.TrimSpace(string(out))) + } + return nil +} + +func startHarnessProcesses(ctx context.Context, cfg config) (runningHarness, error) { + brokerCmd, err := startBrokerProcess(ctx, cfg) + if err != nil { + return runningHarness{}, err + } + tuiCmd, tuiOut, tuiIn, err := startTUIProcess(ctx, cfg) + if err != nil { + terminateProcess(brokerCmd.Process) + return runningHarness{}, err + } + return runningHarness{ctx: ctx, brokerCmd: brokerCmd, tuiCmd: tuiCmd, tuiOut: tuiOut, tuiIn: tuiIn}, nil +} + +func requireTUIFixtureConfig(cfg config) error { + if err := requireIsolationInputs(cfg); err != nil { + return err + } + if cfg.fixtureID != "tui.empty.v1" && cfg.fixtureID != "tui.waiting.v1" { + return usageError{err: fmt.Errorf("mode requires --fixture-id tui.empty.v1|tui.waiting.v1")} + } + if cfg.trials <= 0 { + return usageError{err: fmt.Errorf("--trials must be > 0")} + } + return nil +} + +func prepareTUIIsolation(cfg config) error { + if err := seedFixture(cfg.stateRoot, cfg.fixtureID); err != nil { + return err + } + if err := os.MkdirAll(cfg.runtimeDir, 0o700); err != nil { + return err + } + if err := os.Chmod(cfg.runtimeDir, 0o700); err != nil { + return err + } + return os.MkdirAll(cfg.auditLedgerRoot, 0o700) +} + +func startBrokerProcess(ctx context.Context, cfg config) (*exec.Cmd, error) { + brokerBin := cfg.brokerBin + if brokerBin == "" { + brokerBin = "go" + } + brokerArgs := []string{"--state-root", cfg.stateRoot, "--audit-ledger-root", cfg.auditLedgerRoot, "serve-local", "--runtime-dir", cfg.runtimeDir, "--socket-name", cfg.socketName} + brokerCmd := exec.CommandContext(ctx, brokerBin, brokerArgs...) + if cfg.brokerBin == "" { + brokerCmd = exec.CommandContext(ctx, "go", append([]string{"run", "./cmd/runecode-broker"}, brokerArgs...)...) + } + brokerCmd.Env = os.Environ() + var stdout bytes.Buffer + var stderr bytes.Buffer + brokerCmd.Stdout = &stdout + brokerCmd.Stderr = &stderr + if err := brokerCmd.Start(); err != nil { + return nil, err + } + if err := waitForSocket(filepath.Join(cfg.runtimeDir, cfg.socketName), 5*time.Second); err != nil { + terminateProcess(brokerCmd.Process) + return nil, fmt.Errorf("%w; broker startup summary: %s", err, summarizeBrokerStartupOutput(stdout.String(), stderr.String())) + } + return brokerCmd, nil +} + +func startTUIProcess(ctx context.Context, cfg config) (*exec.Cmd, io.ReadCloser, io.WriteCloser, error) { + tuiBin := cfg.tuiBin + tuiArgs := []string{"--runtime-dir", cfg.runtimeDir, "--socket-name", cfg.socketName} + tuiCmd := exec.CommandContext(ctx, tuiBin, tuiArgs...) + if cfg.tuiBin == "" { + tuiCmd = exec.CommandContext(ctx, "go", append([]string{"run", "./cmd/runecode-tui"}, tuiArgs...)...) + } + tuiCmd.Env = stableTTYEnv(os.Environ()) + tuiCmd.Env = append(tuiCmd.Env, "RUNECODE_TUI_BROKER_TARGET="+cfg.targetAlias) + tty, err := pty.Start(tuiCmd) + if err != nil { + return nil, nil, nil, err + } + return tuiCmd, newTerminalQueryResponder(tty, tty), tty, nil +} + +func stopHarness(h runningHarness) { + terminateProcess(processOf(h.tuiCmd)) + terminateProcess(processOf(h.brokerCmd)) +} + +func processOf(cmd *exec.Cmd) *os.Process { + if cmd == nil { + return nil + } + return cmd.Process +} + +func requireIsolationInputs(cfg config) error { + if cfg.runtimeDir == "" || cfg.socketName == "" || cfg.stateRoot == "" || cfg.auditLedgerRoot == "" || cfg.targetAlias == "" { + return usageError{err: fmt.Errorf("isolation inputs required: --runtime-dir --socket-name --state-root --audit-ledger-root --target-alias")} + } + return nil +} + +func waitForSocket(path string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + info, err := os.Stat(path) + if err == nil && (info.Mode()&os.ModeSocket) != 0 { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("socket not ready: %s", path) + } + time.Sleep(20 * time.Millisecond) + } +} + +func terminateProcess(p *os.Process) { + if p == nil { + return + } + done := make(chan struct{}) + go func() { + _, _ = p.Wait() + close(done) + }() + _ = p.Signal(syscall.SIGTERM) + select { + case <-done: + return + case <-time.After(terminateGracePeriod): + } + _ = p.Signal(syscall.SIGKILL) + select { + case <-done: + case <-time.After(terminateGracePeriod): + } +} + +func summarizeBrokerStartupOutput(stdoutRaw, stderrRaw string) string { + return fmt.Sprintf("stdout=%s stderr=%s", summarizeStartupOutput(stdoutRaw), summarizeStartupOutput(stderrRaw)) +} + +func summarizeStartupOutput(raw string) string { + text := strings.TrimSpace(raw) + if text == "" { + return "" + } + text = nonPrintablePattern.ReplaceAllString(text, "") + text = absPathPattern.ReplaceAllString(text, "") + text = longTokenPattern.ReplaceAllString(text, "") + text = wsPattern.ReplaceAllString(text, " ") + text = strings.TrimSpace(text) + if text == "" { + return "" + } + const maxLen = 200 + if len(text) <= maxLen { + return text + } + return "…" + text[len(text)-maxLen:] +} diff --git a/tools/tuiperf/harness_test.go b/tools/tuiperf/harness_test.go new file mode 100644 index 00000000..7c1b6f8a --- /dev/null +++ b/tools/tuiperf/harness_test.go @@ -0,0 +1,92 @@ +//go:build linux + +package main + +import ( + "bytes" + "io" + "reflect" + "strings" + "testing" +) + +func TestSummarizeStartupOutputSanitizesSensitiveData(t *testing.T) { + t.Parallel() + + raw := "listen failed for /tmp/private/runtime.sock token=abcdefghijklmnopqrstuvwxyz123456" + summary := summarizeStartupOutput(raw) + + if strings.Contains(summary, "/tmp/private/runtime.sock") { + t.Fatalf("summary leaked absolute path: %q", summary) + } + if strings.Contains(summary, "abcdefghijklmnopqrstuvwxyz123456") { + t.Fatalf("summary leaked long token: %q", summary) + } + if !strings.Contains(summary, "") { + t.Fatalf("summary missing redacted path marker: %q", summary) + } + if !strings.Contains(summary, "") { + t.Fatalf("summary missing redacted token marker: %q", summary) + } +} + +func TestSummarizeStartupOutputTruncatesLongOutput(t *testing.T) { + t.Parallel() + + long := strings.Repeat("segment ", 40) + summary := summarizeStartupOutput(long) + if !strings.HasPrefix(summary, "…") { + t.Fatalf("summary = %q, want ellipsis prefix", summary) + } +} + +func TestSummarizeBrokerStartupOutputIncludesBothStreams(t *testing.T) { + t.Parallel() + + summary := summarizeBrokerStartupOutput("stdout ok", "stderr boom") + if !strings.Contains(summary, "stdout=stdout ok") { + t.Fatalf("summary missing stdout segment: %q", summary) + } + if !strings.Contains(summary, "stderr=stderr boom") { + t.Fatalf("summary missing stderr segment: %q", summary) + } +} + +func TestStableTTYEnvOverridesOrAddsTERM(t *testing.T) { + t.Parallel() + + got := stableTTYEnv([]string{"FOO=bar", "TERM=dumb"}) + if !reflect.DeepEqual(got, []string{"FOO=bar", "TERM=" + stableTUITerm}) { + t.Fatalf("stableTTYEnv override = %v", got) + } + + got = stableTTYEnv([]string{"FOO=bar"}) + if !reflect.DeepEqual(got, []string{"FOO=bar", "TERM=" + stableTUITerm}) { + t.Fatalf("stableTTYEnv append = %v", got) + } +} + +func TestTerminalQueryResponderAnswersSplitQueries(t *testing.T) { + t.Parallel() + + reader := io.NopCloser(strings.NewReader("prefix \x1b]11;?\x1b\\ middle \x1b[6n suffix")) + var responses bytes.Buffer + responder := newTerminalQueryResponder(reader, &responses) + buf := make([]byte, 4) + for { + _, err := responder.Read(buf) + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("Read error = %v", err) + } + } + got := responses.String() + if !strings.Contains(got, terminalBackgroundColorResponse) { + t.Fatalf("responses missing background color response: %q", got) + } + if !strings.Contains(got, terminalCPRResponse) { + t.Fatalf("responses missing CPR response: %q", got) + } +} diff --git a/tools/tuiperf/main.go b/tools/tuiperf/main.go new file mode 100644 index 00000000..74af16f0 --- /dev/null +++ b/tools/tuiperf/main.go @@ -0,0 +1,21 @@ +//go:build linux + +package main + +import ( + "errors" + "fmt" + "os" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + var usageErr usageError + if errors.As(err, &usageErr) { + fmt.Fprintf(os.Stderr, "tuiperf usage error: %v\n", err) + os.Exit(2) + } + fmt.Fprintf(os.Stderr, "tuiperf failed: %v\n", err) + os.Exit(1) + } +} diff --git a/tools/tuiperf/modes.go b/tools/tuiperf/modes.go new file mode 100644 index 00000000..5adbdaa6 --- /dev/null +++ b/tools/tuiperf/modes.go @@ -0,0 +1,189 @@ +//go:build linux + +package main + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/runecode-ai/runecode/internal/perfcontracts" + "github.com/runecode-ai/runecode/internal/tuiperf" +) + +const latencyMarkerTimeout = 20 * time.Second + +func runCPUMode(cfg config) error { + _, cancel, harness, err := startTUIHarness(cfg) + if err != nil { + return err + } + defer cancel() + defer stopHarness(harness) + targetPID, err := tuiperf.WaitForChildByComm("/proc", harness.tuiCmd.Process.Pid, "runecode-tui", 3*time.Second, 20*time.Millisecond) + if err != nil { + return err + } + result, err := tuiperf.SampleProcessCPU(targetPID, tuiperf.CPUSampleConfig{Warmup: cfg.warmup, Window: cfg.window, Windows: cfg.windows}) + if err != nil { + return err + } + measurements := cpuMeasurementsForFixture(cfg.fixtureID, result) + return writeEnvelope(cfg.outputPath, checkEnvelope{SchemaVersion: checkSchemaVersion, Metadata: map[string]any{"mode": "cpu", "fixture_id": cfg.fixtureID, "target_pid": result.TargetPID, "target_comm": result.TargetComm, "sampling": result}, Measurements: measurements}) +} + +func runLatencyMode(cfg config) error { + preparedCfg, cleanup, err := prepareLatencyMode(cfg) + if err != nil { + return err + } + defer cleanup() + attachDurations, keyDurations, err := collectLatencySamplesFromFreshSpawn( + preparedCfg.trials, + func() (runningHarness, error) { return startLatencyHarness(preparedCfg) }, + stopLatencyHarness, + collectLatencySampleFromHarness, + ) + if err != nil { + return err + } + attachP95, keyP95, err := latencyP95(attachDurations, keyDurations) + if err != nil { + return err + } + measurements := latencyMeasurementsForFixture(preparedCfg.fixtureID, attachP95, keyP95) + return writeEnvelope(preparedCfg.outputPath, checkEnvelope{SchemaVersion: checkSchemaVersion, Metadata: map[string]any{"mode": "latency", "fixture_id": preparedCfg.fixtureID, "trials": preparedCfg.trials, "attach_samples_ms": attachDurations, "key_samples_ms": keyDurations}, Measurements: measurements}) +} + +func prepareLatencyMode(cfg config) (config, func(), error) { + if err := requireTUIFixtureConfig(cfg); err != nil { + return config{}, nil, err + } + preparedCfg, err := prepareHarnessBinaries(cfg) + if err != nil { + return config{}, nil, err + } + cleanup := func() { cleanupHarnessBinaries(preparedCfg) } + if err := prepareTUIIsolation(cfg); err != nil { + cleanup() + return config{}, nil, err + } + return preparedCfg, cleanup, nil +} + +func startLatencyHarness(cfg config) (runningHarness, error) { + ctx, cancel := context.WithTimeout(context.Background(), cfg.timeout) + harness, err := startHarnessProcesses(ctx, cfg) + if err != nil { + cancel() + return runningHarness{}, err + } + harness.ctx = ctx + harness.cancel = cancel + return harness, nil +} + +func stopLatencyHarness(h runningHarness) { + stopHarness(h) + if h.cancel != nil { + h.cancel() + } +} + +func cpuMeasurementsForFixture(fixtureID string, result tuiperf.CPUSampleResult) []perfcontracts.MeasurementRecord { + if fixtureID == "tui.empty.v1" { + return []perfcontracts.MeasurementRecord{{MetricID: "metric.tui.idle_cpu.empty.avg_pct", Value: result.AverageCPUPercent, Unit: "percent"}, {MetricID: "metric.tui.idle_cpu.empty.max_pct", Value: result.MaxCPUPercent, Unit: "percent"}} + } + return []perfcontracts.MeasurementRecord{{MetricID: "metric.tui.idle_cpu.waiting.avg_pct", Value: result.AverageCPUPercent, Unit: "percent"}, {MetricID: "metric.tui.idle_cpu.waiting.max_pct", Value: result.MaxCPUPercent, Unit: "percent"}} +} + +func collectLatencySamplesFromFreshSpawn( + trials int, + startHarness func() (runningHarness, error), + stopHarnessFn func(runningHarness), + collectSample func(runningHarness, string, time.Time) (float64, float64, error), +) ([]float64, []float64, error) { + marker := "Runecode TUI α shell" + attachDurations := make([]float64, 0, trials) + keyDurations := make([]float64, 0, trials) + for i := 0; i < trials; i++ { + start := time.Now() + h, err := startHarness() + if err != nil { + return nil, nil, err + } + attachMS, keyMS, err := func() (float64, float64, error) { + defer stopHarnessFn(h) + return collectSample(h, marker, start) + }() + if err != nil { + return nil, nil, err + } + attachDurations = append(attachDurations, attachMS) + keyDurations = append(keyDurations, keyMS) + } + return attachDurations, keyDurations, nil +} + +func collectLatencySampleFromHarness(h runningHarness, marker string, start time.Time) (float64, float64, error) { + events := make(chan tuiperf.MarkerEvent, 64) + const keyResponseMarker = "focus=MAIN" + go tuiperf.WatchMarkers(h.ctx, h.tuiOut, []string{marker, keyResponseMarker}, events) + attachAt, err := waitForMarker(events, marker, latencyMarkerTimeout) + if err != nil { + return 0, 0, err + } + keyStart := time.Now() + if _, err := io.WriteString(h.tuiIn, "\t"); err != nil { + return 0, 0, err + } + keyAt, err := waitForMarkerAfter(events, keyResponseMarker, keyStart, latencyMarkerTimeout) + if err != nil { + return 0, 0, err + } + return float64(attachAt.Sub(start).Milliseconds()), float64(keyAt.Sub(keyStart).Milliseconds()), nil +} + +func latencyP95(attachDurations, keyDurations []float64) (float64, float64, error) { + attachP95, err := tuiperf.P95Millis(attachDurations) + if err != nil { + return 0, 0, err + } + keyP95, err := tuiperf.P95Millis(keyDurations) + if err != nil { + return 0, 0, err + } + return attachP95, keyP95, nil +} + +func latencyMeasurementsForFixture(fixtureID string, attachP95, keyP95 float64) []perfcontracts.MeasurementRecord { + if fixtureID == "tui.empty.v1" { + return []perfcontracts.MeasurementRecord{{MetricID: "metric.tui.attach.quiet.p95_ms", Value: attachP95, Unit: "ms"}, {MetricID: "metric.tui.key_response.quiet.p95_ms", Value: keyP95, Unit: "ms"}} + } + return []perfcontracts.MeasurementRecord{{MetricID: "metric.tui.attach.waiting.p95_ms", Value: attachP95, Unit: "ms"}, {MetricID: "metric.tui.key_response.waiting.p95_ms", Value: keyP95, Unit: "ms"}} +} + +func runBenchParseMode(cfg config) error { + if strings.TrimSpace(cfg.benchOutput) == "" { + return usageError{err: fmt.Errorf("bench-parse mode requires --bench-output")} + } + file, err := os.Open(cfg.benchOutput) + if err != nil { + return err + } + defer file.Close() + measurements, err := tuiperf.ParseGoTestBenchOutput(file, []tuiperf.BenchmarkMetricMap{ + {Benchmark: "BenchmarkShellViewEmpty", Field: "ns/op", MetricID: "metric.tui.render.shell_view_empty.ns_op", Unit: "ns/op"}, + {Benchmark: "BenchmarkShellViewWaitingSession", Field: "ns/op", MetricID: "metric.tui.render.shell_view_waiting.ns_op", Unit: "ns/op"}, + {Benchmark: "BenchmarkShellWatchApply", Field: "ns/op", MetricID: "metric.tui.update.shell_watch_apply.ns_op", Unit: "ns/op"}, + {Benchmark: "BenchmarkBuildPaletteEntries", Field: "ns/op", MetricID: "metric.tui.update.build_palette_entries.ns_op", Unit: "ns/op"}, + }) + if err != nil { + return err + } + return writeEnvelope(cfg.outputPath, checkEnvelope{SchemaVersion: checkSchemaVersion, Metadata: map[string]any{"mode": "bench-parse", "bench_output": filepath.Base(cfg.benchOutput)}, Measurements: measurements}) +} diff --git a/tools/tuiperf/modes_test.go b/tools/tuiperf/modes_test.go new file mode 100644 index 00000000..54ab2a9a --- /dev/null +++ b/tools/tuiperf/modes_test.go @@ -0,0 +1,221 @@ +//go:build linux + +package main + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/runecode-ai/runecode/internal/tuiperf" +) + +func TestRunModeUnsupportedModeReturnsUsageError(t *testing.T) { + t.Parallel() + + err := runMode(config{mode: "unknown"}) + if err == nil { + t.Fatal("runMode error = nil, want usage error") + } + var usageErr usageError + if !errors.As(err, &usageErr) { + t.Fatalf("runMode error = %T, want usageError", err) + } +} + +func TestRunBenchParseModeRequiresBenchOutputAsUsageError(t *testing.T) { + t.Parallel() + + err := runBenchParseMode(config{}) + if err == nil { + t.Fatal("runBenchParseMode error = nil, want usage error") + } + var usageErr usageError + if !errors.As(err, &usageErr) { + t.Fatalf("runBenchParseMode error = %T, want usageError", err) + } +} + +func TestCollectLatencySamplesFromFreshSpawnStartsAndStopsPerTrial(t *testing.T) { + t.Parallel() + + started, stopped, attach, key, err := collectTrialLifecycleSamples(t) + if err != nil { + t.Fatalf("collectLatencySamplesFromFreshSpawn error = %v", err) + } + assertTrialLifecycle(t, started, stopped, attach, key) +} + +func collectTrialLifecycleSamples(t *testing.T) ([]string, []string, []float64, []float64, error) { + t.Helper() + var started []string + var stopped []string + sampleCalls := 0 + attach, key, err := collectLatencySamplesFromFreshSpawn( + 3, + func() (runningHarness, error) { + id := fmt.Sprintf("trial-%d", len(started)+1) + started = append(started, id) + return runningHarness{tuiCmd: &exec.Cmd{Path: id}}, nil + }, + func(h runningHarness) { stopped = append(stopped, h.tuiCmd.Path) }, + func(_ runningHarness, marker string, _ time.Time) (float64, float64, error) { + sampleCalls++ + if marker != "Runecode TUI α shell" { + t.Fatalf("marker = %q, want %q", marker, "Runecode TUI α shell") + } + return float64(sampleCalls), float64(sampleCalls + 10), nil + }, + ) + return started, stopped, attach, key, err +} + +func assertTrialLifecycle(t *testing.T, started, stopped []string, attach, key []float64) { + t.Helper() + assertLifecycleCounts(t, started, stopped, attach, key) + assertLifecycleOrder(t, started, stopped) + assertLifecycleSamples(t, attach, key) +} + +func assertLifecycleCounts(t *testing.T, started, stopped []string, attach, key []float64) { + t.Helper() + if got, want := len(started), 3; got != want { + t.Fatalf("start calls = %d, want %d", got, want) + } + if got, want := len(stopped), 3; got != want { + t.Fatalf("stop calls = %d, want %d", got, want) + } + if got, want := len(attach), 3; got != want { + t.Fatalf("attach sample count = %d, want %d", got, want) + } + if got, want := len(key), 3; got != want { + t.Fatalf("key sample count = %d, want %d", got, want) + } +} + +func assertLifecycleOrder(t *testing.T, started, stopped []string) { + t.Helper() + for i := range started { + if started[i] != stopped[i] { + t.Fatalf("stopped[%d] = %q, want %q", i, stopped[i], started[i]) + } + } +} + +func assertLifecycleSamples(t *testing.T, attach, key []float64) { + t.Helper() + if attach[0] != 1 || attach[1] != 2 || attach[2] != 3 { + t.Fatalf("attach samples = %v, want [1 2 3]", attach) + } + if key[0] != 11 || key[1] != 12 || key[2] != 13 { + t.Fatalf("key samples = %v, want [11 12 13]", key) + } +} + +func TestCollectLatencySamplesFromFreshSpawnStopsHarnessOnSampleError(t *testing.T) { + t.Parallel() + + var stopped []string + starts := 0 + + _, _, err := collectLatencySamplesFromFreshSpawn( + 3, + func() (runningHarness, error) { + starts++ + return runningHarness{tuiCmd: &exec.Cmd{Path: fmt.Sprintf("trial-%d", starts)}}, nil + }, + func(h runningHarness) { + stopped = append(stopped, h.tuiCmd.Path) + }, + func(h runningHarness, _ string, _ time.Time) (float64, float64, error) { + if h.tuiCmd.Path == "trial-2" { + return 0, 0, errors.New("sample failed") + } + return 1, 1, nil + }, + ) + if err == nil { + t.Fatal("collectLatencySamplesFromFreshSpawn error = nil, want error") + } + if got, want := starts, 2; got != want { + t.Fatalf("start calls = %d, want %d", got, want) + } + if got, want := len(stopped), 2; got != want { + t.Fatalf("stop calls = %d, want %d", got, want) + } + if stopped[1] != "trial-2" { + t.Fatalf("stopped harness on error = %q, want trial-2", stopped[1]) + } +} + +func TestCollectLatencySamplesFromFreshSpawnMeasuresFromPreSpawnStart(t *testing.T) { + t.Parallel() + + spawnDelay := 15 * time.Millisecond + _, _, err := collectLatencySamplesFromFreshSpawn( + 1, + func() (runningHarness, error) { + time.Sleep(spawnDelay) + return runningHarness{tuiCmd: &exec.Cmd{Path: "trial-1"}}, nil + }, + func(runningHarness) {}, + func(_ runningHarness, _ string, start time.Time) (float64, float64, error) { + if elapsed := time.Since(start); elapsed < spawnDelay { + t.Fatalf("elapsed since start = %s, want >= %s", elapsed, spawnDelay) + } + return 1, 1, nil + }, + ) + if err != nil { + t.Fatalf("collectLatencySamplesFromFreshSpawn error = %v", err) + } +} + +func TestWaitForMarkerAfterSkipsStaleEvents(t *testing.T) { + t.Parallel() + events := make(chan tuiperf.MarkerEvent, 3) + start := time.Now() + events <- tuiperf.MarkerEvent{Marker: "Runecode TUI α shell", At: start.Add(-time.Millisecond)} + events <- tuiperf.MarkerEvent{Marker: "Runecode TUI α shell", At: start.Add(time.Millisecond)} + got, err := waitForMarkerAfter(events, "Runecode TUI α shell", start, time.Second) + if err != nil { + t.Fatalf("waitForMarkerAfter error = %v", err) + } + if got.Before(start) { + t.Fatalf("got = %s, want >= %s", got, start) + } +} + +func TestRunBenchParseModeStoresBenchOutputBaseName(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + benchPath := filepath.Join(tmp, "bench.txt") + outputPath := filepath.Join(tmp, "out.json") + content := strings.Join([]string{ + "BenchmarkShellViewEmpty-8 1000 10 ns/op", + "BenchmarkShellViewWaitingSession-8 1000 11 ns/op", + "BenchmarkShellWatchApply-8 1000 12 ns/op", + "BenchmarkBuildPaletteEntries-8 1000 13 ns/op", + }, "\n") + "\n" + if err := os.WriteFile(benchPath, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile benchPath: %v", err) + } + if err := runBenchParseMode(config{benchOutput: benchPath, outputPath: outputPath}); err != nil { + t.Fatalf("runBenchParseMode error = %v", err) + } + raw, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("ReadFile output: %v", err) + } + if strings.Contains(string(raw), benchPath) { + t.Fatalf("output leaked full bench path: %s", benchPath) + } + if !strings.Contains(string(raw), filepath.Base(benchPath)) { + t.Fatalf("output missing bench basename: %s", filepath.Base(benchPath)) + } +} diff --git a/tools/tuiperf/terminal_queries.go b/tools/tuiperf/terminal_queries.go new file mode 100644 index 00000000..70bbee92 --- /dev/null +++ b/tools/tuiperf/terminal_queries.go @@ -0,0 +1,74 @@ +//go:build linux + +package main + +import ( + "io" + "strings" +) + +const stableTUITerm = "xterm-256color" + +const ( + terminalCPRQuery = "\x1b[6n" + terminalCPRResponse = "\x1b[1;1R" + terminalBackgroundColorQuery = "\x1b]11;?\x1b\\" + terminalBackgroundColorResponse = "\x1b]11;rgb:0000/0000/0000\x1b\\" +) + +type terminalQueryResponder struct { + io.ReadCloser + w io.Writer + pending string +} + +func newTerminalQueryResponder(r io.ReadCloser, w io.Writer) io.ReadCloser { + return &terminalQueryResponder{ReadCloser: r, w: w} +} + +func (r *terminalQueryResponder) Read(p []byte) (int, error) { + n, err := r.ReadCloser.Read(p) + if n > 0 { + r.respondToTerminalQueries(p[:n]) + } + return n, err +} + +func (r *terminalQueryResponder) respondToTerminalQueries(chunk []byte) { + text := r.pending + string(chunk) + if strings.Contains(text, terminalCPRQuery) { + _, _ = io.WriteString(r.w, terminalCPRResponse) + } + if strings.Contains(text, terminalBackgroundColorQuery) { + _, _ = io.WriteString(r.w, terminalBackgroundColorResponse) + } + r.pending = terminalQueryTail(text) +} + +func terminalQueryTail(text string) string { + keep := len(terminalBackgroundColorQuery) - 1 + if len(terminalCPRQuery) > len(terminalBackgroundColorQuery) { + keep = len(terminalCPRQuery) - 1 + } + if len(text) <= keep { + return text + } + return text[len(text)-keep:] +} + +func stableTTYEnv(base []string) []string { + filtered := make([]string, 0, len(base)+1) + hasTerm := false + for _, entry := range base { + if strings.HasPrefix(entry, "TERM=") { + filtered = append(filtered, "TERM="+stableTUITerm) + hasTerm = true + continue + } + filtered = append(filtered, entry) + } + if !hasTerm { + filtered = append(filtered, "TERM="+stableTUITerm) + } + return filtered +} diff --git a/tools/tuiperf/types.go b/tools/tuiperf/types.go new file mode 100644 index 00000000..af525047 --- /dev/null +++ b/tools/tuiperf/types.go @@ -0,0 +1,38 @@ +//go:build linux + +package main + +import ( + "time" + + "github.com/runecode-ai/runecode/internal/perfcontracts" +) + +const checkSchemaVersion = "runecode.performance.check.v1" + +type config struct { + mode string + outputPath string + fixtureID string + runtimeDir string + socketName string + stateRoot string + auditLedgerRoot string + targetAlias string + repoRoot string + harnessBinDir string + brokerBin string + tuiBin string + trials int + warmup time.Duration + window time.Duration + windows int + timeout time.Duration + benchOutput string +} + +type checkEnvelope struct { + SchemaVersion string `json:"schema_version"` + Metadata map[string]any `json:"metadata,omitempty"` + Measurements []perfcontracts.MeasurementRecord `json:"measurements"` +} diff --git a/tools/tuiperf/util.go b/tools/tuiperf/util.go new file mode 100644 index 00000000..94afba66 --- /dev/null +++ b/tools/tuiperf/util.go @@ -0,0 +1,82 @@ +//go:build linux + +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "strings" + "time" + + "github.com/runecode-ai/runecode/internal/tuiperf" +) + +func waitForMarker(events <-chan tuiperf.MarkerEvent, marker string, timeout time.Duration) (time.Time, error) { + deadline := time.After(timeout) + for { + select { + case ev, ok := <-events: + if !ok { + return time.Time{}, fmt.Errorf("marker stream closed before marker %q", marker) + } + if ev.Marker == marker { + return ev.At, nil + } + case <-deadline: + return time.Time{}, fmt.Errorf("timeout waiting for marker %q", marker) + } + } +} + +func waitForMarkerAfter(events <-chan tuiperf.MarkerEvent, marker string, earliest time.Time, timeout time.Duration) (time.Time, error) { + deadline := time.After(timeout) + for { + select { + case ev, ok := <-events: + if !ok { + return time.Time{}, fmt.Errorf("marker stream closed before marker %q", marker) + } + if ev.Marker == marker && !ev.At.Before(earliest) { + return ev.At, nil + } + case <-deadline: + return time.Time{}, fmt.Errorf("timeout waiting for marker %q", marker) + } + } +} + +func seedFixture(storeRoot, fixtureID string) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "run", "./tools/perfseedwait", "--fixture-id", fixtureID, "--store-root", storeRoot) + var stderr bytes.Buffer + cmd.Stdout = io.Discard + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + if ctx.Err() != nil { + return fmt.Errorf("seed fixture %s timed out: %w", fixtureID, ctx.Err()) + } + return fmt.Errorf("seed fixture %s: %w: %s", fixtureID, err, strings.TrimSpace(stderr.String())) + } + return nil +} + +func writeEnvelope(path string, envelope checkEnvelope) error { + raw, err := json.MarshalIndent(envelope, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, raw, 0o644) +} + +func shellEscape(v string) string { + if v == "" { + return "''" + } + return "'" + strings.ReplaceAll(v, "'", "'\\''") + "'" +}