diff --git a/README.md b/README.md index c85e264f..7928c894 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ 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.11` 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. +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 @@ -283,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 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/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/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/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_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 f9ecaa64..e2f79ff0 100644 --- a/internal/brokerapi/local_api_ops_posture_evidence_test.go +++ b/internal/brokerapi/local_api_ops_posture_evidence_test.go @@ -1,528 +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 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"]) - } -} - -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 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"]) - } -} - -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_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 25f65b36..f882032e 100644 --- a/internal/brokerapi/local_api_run_detail_state_authoritative_ops.go +++ b/internal/brokerapi/local_api_run_detail_state_authoritative_ops.go @@ -209,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_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/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/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_approval_fixture.go b/internal/brokerperf/harness_approval_fixture.go index 99222ac1..0337a601 100644 --- a/internal/brokerperf/harness_approval_fixture.go +++ b/internal/brokerperf/harness_approval_fixture.go @@ -20,6 +20,10 @@ import ( ) 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 @@ -35,7 +39,7 @@ func seedBackendPostureApprovalForResolve(service *brokerapi.Service) (brokerapi if err := putTrustedVerifierRecordForService(service, verifierRecord); err != nil { return brokerapi.ApprovalResolveRequest{}, err } - policyHash, err := persistBackendPostureApprovalFixture(service, targetInstanceID, actionHash, approvalID, requestEnv) + policyHash, err := persistBackendPostureApprovalFixture(service, targetInstanceID, actionHash, approvalID, requestEnv, runID) if err != nil { return brokerapi.ApprovalResolveRequest{}, err } @@ -49,7 +53,7 @@ func seedBackendPostureApprovalForResolve(service *brokerapi.Service) (brokerapi SchemaVersion: "0.1.0", WorkspaceID: "workspace-local", InstanceID: targetInstanceID, - RunID: "run-backend", + RunID: strings.TrimSpace(runID), ActionKind: policyengine.ActionKindBackendPosture, PolicyDecisionHash: policyHash, }, @@ -177,26 +181,46 @@ func marshalBackendPostureDecisionPayload(approvalID string, verifier trustpolic return json.Marshal(decisionPayload) } -func persistBackendPostureApprovalFixture(service *brokerapi.Service, targetInstanceID, actionHash, approvalID string, requestEnv trustpolicy.SignedObjectEnvelope) (string, error) { - 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": map[string]any{"schema_id": "runecode.protocol.v0.ApprovalBoundScope", "schema_version": "0.1.0", "workspace_id": "workspace-local", "run_id": "run-backend", "instance_id": targetInstanceID, "action_kind": policyengine.ActionKindBackendPosture}, "changes_if_approved": "Reduced-assurance backend posture change may be applied.", "approval_ttl_seconds": 1800}} - if err := service.RecordPolicyDecision("run-backend", "", policyDecision); err != nil { - return "", err +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" } - refs := service.PolicyDecisionRefsForRun("run-backend") - if len(refs) == 0 { - return "", fmt.Errorf("missing policy decision refs") + 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 } - policyHash := refs[len(refs)-1] - if err := recordPendingApproval(service, targetInstanceID, actionHash, approvalID, policyHash, requestEnv); err != nil { + 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, targetInstanceID, actionHash, approvalID, policyHash string, requestEnv trustpolicy.SignedObjectEnvelope) error { +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: "run-backend", 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} + 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) } diff --git a/internal/brokerperf/harness_measurements.go b/internal/brokerperf/harness_measurements.go index 5b7c3e8c..a75f8e91 100644 --- a/internal/brokerperf/harness_measurements.go +++ b/internal/brokerperf/harness_measurements.go @@ -76,13 +76,33 @@ func measureWatches(trials int, repoRoot string) ([]perfcontracts.MeasurementRec func measureMutations(trials int, repoRoot string) ([]perfcontracts.MeasurementRecord, error) { ctx := context.Background() - specs := []latencySpec{ - {metricID: "metric.broker.mutation.session_execution_trigger.p95_ms", call: func() error { return measureSessionExecutionTriggerMutation(ctx, repoRoot) }}, - {metricID: "metric.broker.mutation.session_execution_continue.p95_ms", call: func() error { return measureSessionExecutionContinueMutation(ctx, repoRoot) }}, - {metricID: "metric.broker.mutation.approval_resolve.p95_ms", call: func() error { return measureApprovalResolveMutation(ctx, repoRoot) }}, - {metricID: "metric.broker.mutation.backend_posture_change.p95_ms", call: func() error { return measureBackendPostureChangeFixture(repoRoot) }}, + 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 collectLatencyMeasurements(trials, specs) + return p95Records(latency) } func measureAttachResume(trials int, repoRoot string) ([]perfcontracts.MeasurementRecord, error) { @@ -138,63 +158,92 @@ func timedProductLifecyclePostureGet(ctx context.Context, service *brokerapi.Ser }) } -func measureSessionExecutionTriggerMutation(ctx context.Context, repoRoot string) error { +func measureSessionExecutionTriggerMutation(ctx context.Context, repoRoot string) (float64, error) { service, cleanup, err := newSeededService(repoRoot) if err != nil { - return err + return 0, err } defer cleanup() - _, errResp := service.HandleSessionExecutionTrigger(ctx, brokerapi.SessionExecutionTriggerRequest{SchemaID: "runecode.protocol.v0.SessionExecutionTriggerRequest", SchemaVersion: "0.1.0", RequestID: "perf-trigger", 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: "trigger"}, brokerapi.RequestContext{}) - if errResp != nil { - return fmt.Errorf("session_execution_trigger: %s", errResp.Error.Code) - } - return nil + 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) error { +func measureSessionExecutionContinueMutation(ctx context.Context, repoRoot string) (float64, error) { service, cleanup, err := newSeededService(repoRoot) if err != nil { - return err + 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: "interactive_user", RequestedOperation: "start", WorkflowRouting: &brokerapi.SessionWorkflowPackRouting{SchemaID: "runecode.protocol.v0.SessionWorkflowPackRouting", SchemaVersion: "0.1.0", WorkflowFamily: "runecontext", WorkflowOperation: "change_draft"}, UserMessageContentText: "start"}, brokerapi.RequestContext{}) + 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 fmt.Errorf("continue start seed: %s", errResp.Error.Code) + return 0, fmt.Errorf("continue start seed: %s", errResp.Error.Code) } - _, _ = 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()}) - _, 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"}, UserMessageContentText: "continue"}, brokerapi.RequestContext{}) - if errResp != nil { - return fmt.Errorf("session_execution_continue: %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 nil + 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) error { +func measureApprovalResolveMutation(ctx context.Context, repoRoot string) (float64, error) { service, cleanup, err := newSeededService(repoRoot) if err != nil { - return err + return 0, err } defer cleanup() - resolveReq, err := seedBackendPostureApprovalForResolve(service) + resolveReq, err := seedBackendPostureApprovalForResolveWithRunID(service, "") if err != nil { - return err + return 0, err } - _, 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 + 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 measureBackendPostureChangeFixture(repoRoot string) error { +func measureBackendPostureChangeMutation(ctx context.Context, repoRoot string) (float64, error) { service, cleanup, err := newSeededService(repoRoot) if err != nil { - return err + return 0, err } defer cleanup() - _, err = seedBackendPostureApprovalForResolve(service) - if err != nil { - return fmt.Errorf("backend_posture_change fixture: %w", err) + 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 nil + 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_test.go b/internal/brokerperf/harness_test.go index 32ac262a..53c1801f 100644 --- a/internal/brokerperf/harness_test.go +++ b/internal/brokerperf/harness_test.go @@ -1,10 +1,12 @@ package brokerperf import ( + "context" "path/filepath" "runtime" "testing" + "github.com/runecode-ai/runecode/internal/brokerapi" "github.com/runecode-ai/runecode/internal/perfcontracts" ) @@ -45,6 +47,84 @@ func TestP95RecordsRejectsEmptySampleSet(t *testing.T) { } } +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 { diff --git a/internal/perfcontracts/evaluate.go b/internal/perfcontracts/evaluate.go index 405242ab..e9e71f09 100644 --- a/internal/perfcontracts/evaluate.go +++ b/internal/perfcontracts/evaluate.go @@ -1,9 +1,6 @@ package perfcontracts -import ( - "fmt" - "math" -) +import "fmt" type Violation struct { MetricID string @@ -159,7 +156,3 @@ func median(values []float64) float64 { } return cp[m] } - -func almostEqual(a, b float64) bool { - return math.Abs(a-b) < 1e-9 -} diff --git a/internal/perfcontracts/evaluate_test.go b/internal/perfcontracts/evaluate_test.go index 15c40726..2f1437d8 100644 --- a/internal/perfcontracts/evaluate_test.go +++ b/internal/perfcontracts/evaluate_test.go @@ -51,6 +51,13 @@ func exactAndAbsoluteComparisonTests(max500 float64) []comparisonMethodTestCase 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}}, diff --git a/internal/perfcontracts/types.go b/internal/perfcontracts/types.go index 433a310c..0fee31a8 100644 --- a/internal/perfcontracts/types.go +++ b/internal/perfcontracts/types.go @@ -5,6 +5,7 @@ type Manifest struct { 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"` @@ -53,25 +54,26 @@ type ContractFile struct { } 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"` - 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"` + 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 { diff --git a/internal/perfcontracts/validate.go b/internal/perfcontracts/validate.go index eb93f5a4..2afe7696 100644 --- a/internal/perfcontracts/validate.go +++ b/internal/perfcontracts/validate.go @@ -27,6 +27,12 @@ var allowedActivationStates = map[string]struct{}{ "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": {}, @@ -42,6 +48,7 @@ func ValidateWithBaselines(manifest Manifest, inventory FixtureInventory, contra if err := validateManifestAndInventory(manifest, inventory); err != nil { return err } + reviewedMeasurementProfiles := measurementProfileSet(manifest.MeasurementProfiles) baselineRefsByMetric, err := baselineRefSet(manifest.Baselines) if err != nil { return err @@ -50,7 +57,7 @@ func ValidateWithBaselines(manifest Manifest, inventory FixtureInventory, contra if err != nil { return err } - return validateContracts(contracts, fixtures, baselinesByMetric, baselineRefsByMetric) + return validateContracts(contracts, fixtures, reviewedMeasurementProfiles, baselinesByMetric, baselineRefsByMetric) } func validateManifestAndInventory(manifest Manifest, inventory FixtureInventory) error { @@ -60,6 +67,15 @@ func validateManifestAndInventory(manifest Manifest, inventory FixtureInventory) 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 } @@ -74,6 +90,14 @@ func fixtureSet(inventory FixtureInventory) (map[string]struct{}, error) { 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 { @@ -85,29 +109,29 @@ func baselineRefSet(entries []ManifestBaseline) (map[string]string, error) { return refs, nil } -func validateContracts(contracts []ContractFile, fixtures map[string]struct{}, baselinesByMetric map[string]BaselineFile, baselineRefsByMetric map[string]string) error { +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, baselinesByMetric, baselineRefsByMetric); err != nil { + if err := validateContract(contract, fixtures, reviewedMeasurementProfiles, baselinesByMetric, baselineRefsByMetric); err != nil { return err } } return nil } -func validateContract(contract ContractFile, fixtures map[string]struct{}, baselinesByMetric map[string]BaselineFile, baselineRefsByMetric map[string]string) error { +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, baselinesByMetric, baselineRefsByMetric); err != nil { + 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{}, baselinesByMetric map[string]BaselineFile, baselineRefsByMetric map[string]string) error { - checks := []func(MetricContract, map[string]struct{}, map[string]BaselineFile, map[string]string) error{ +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, @@ -116,21 +140,21 @@ func validateMetric(metric MetricContract, fixtures map[string]struct{}, baselin validateMetricBaseline, } for _, check := range checks { - if err := check(metric, fixtures, baselinesByMetric, baselineRefsByMetric); err != nil { + if err := check(metric, fixtures, reviewedMeasurementProfiles, baselinesByMetric, baselineRefsByMetric); err != nil { return err } } return nil } -func validateMetricIdentity(metric MetricContract, _ map[string]struct{}, _ map[string]BaselineFile, _ map[string]string) error { +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{}, _ map[string]BaselineFile, _ map[string]string) error { +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) } @@ -140,17 +164,25 @@ func validateMetricEnums(metric MetricContract, _ map[string]struct{}, _ map[str 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]BaselineFile, _ map[string]string) error { +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]BaselineFile, _ map[string]string) error { +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") } @@ -160,7 +192,7 @@ func validateMetricThresholdOrigin(metric MetricContract, _ map[string]struct{}, return nil } -func validateMetricTimingBoundary(metric MetricContract, _ map[string]struct{}, _ map[string]BaselineFile, _ map[string]string) error { +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") @@ -174,7 +206,7 @@ func validateMetricTimingBoundary(metric MetricContract, _ map[string]struct{}, return nil } -func validateMetricBaseline(metric MetricContract, _ map[string]struct{}, baselinesByMetric map[string]BaselineFile, baselineRefsByMetric map[string]string) error { +func validateMetricBaseline(metric MetricContract, _ map[string]struct{}, _ map[string]struct{}, baselinesByMetric map[string]BaselineFile, baselineRefsByMetric map[string]string) error { if !requiresBaselineValidation(metric) { return nil } diff --git a/internal/perfcontracts/validate_test.go b/internal/perfcontracts/validate_test.go index bba530da..e757e22a 100644 --- a/internal/perfcontracts/validate_test.go +++ b/internal/perfcontracts/validate_test.go @@ -29,6 +29,84 @@ func TestValidateAcceptsReviewedMetricContract(t *testing.T) { } } +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"}}} 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/justfile b/justfile index f74e32f5..46db8e0d 100644 --- a/justfile +++ b/justfile @@ -15,6 +15,7 @@ lint: cd runner && npm run boundary-check test: + cd runner && npm ci go test ./... cd runner && npm test @@ -32,9 +33,9 @@ ci-fast: 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 @@ -56,9 +57,9 @@ ci-portability: 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/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-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-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-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 224e0084..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 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 index a25d2351..0d5ef264 100644 --- 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 @@ -7,18 +7,19 @@ It does not redefine the major architecture already in place. Instead, it sequen The central rule of this lane is: -RuneCode should not claim beta readiness until one real workflow path runs through the real trusted and untrusted execution path, produces inspectable artifacts and audit evidence, and remains understandable to an operator using the normal product surfaces. +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 five connected concerns: +This lane covers six connected concerns: 1. end-to-end workflow execution wiring 2. trusted `RunPlan` production adoption -3. truthful runtime-attestation posture handoff to `CHG-2026-054-6c1e-runtime-attestation-post-handshake-gating-v0` -4. product and TUI polish discovered during dogfooding -5. release-surface and verification-smoke-path alignment +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 @@ -39,13 +40,14 @@ The main remaining gap is production wiring. The required alpha.11 execution path is: 1. operator starts or attaches to the repo-scoped product via `runecode` -2. operator triggers a supported first-party RuneContext workflow through the normal product path -3. trusted code validates project substrate and execution preconditions -4. trusted code selects and adopts the authoritative built-in workflow assets and compiles the exact immutable `RunPlan` -5. trusted code persists the authoritative plan and its execution bindings -6. the actual runner or isolate-backed execution path starts from that plan -7. runner checkpoints and results flow back through the broker's real typed surfaces -8. operator-visible session, run, artifact, approval, and audit surfaces all reflect that real path +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. @@ -59,18 +61,48 @@ The production path should make it obvious that: - the runner or isolate consumes the authoritative plan identity - later run-state, gate-state, and evidence links resolve back to that exact plan -## First Useful Workflow Slice -This lane should require one useful first-party workflow slice rather than trying to complete every possible path at once. +## Required Beta Workflow Slice +This lane requires the complete local canonical RuneContext workflow loop rather than a single draft-only demonstration. -Recommended minimum target: +Required operations: -- `change_draft` or `spec_draft` from prompt to produced artifact through the real execution path +- `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 -Preferred target if scope remains manageable: +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. -- `change_draft` or `spec_draft` plus `draft_promote_apply`, so the verified RuneContext lifecycle is also exercised through the same honest path +`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. -`approved_change_implementation` may remain follow-on work if needed, but only if beta is explicitly framed as planning- or drafting-first rather than coding-first. +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. @@ -86,10 +118,36 @@ This lane does not replace `CHG-2026-054-6c1e-runtime-attestation-post-handshake 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` lands +- 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. @@ -105,6 +163,8 @@ This lane should capture polish work discovered while testing the real workflow 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 @@ -116,7 +176,10 @@ This lane should require that the real workflow path also exercises the verifica The alpha.11 smoke path should include: -- run one useful workflow +- 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 @@ -131,16 +194,20 @@ This lane should end with roadmap, docs, and messaging that match the real state 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: -- one useful first-party workflow runs through the real trusted and untrusted path +- 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 index ef185e10..772cd04e 100644 --- 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 @@ -1,6 +1,8 @@ ## 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. @@ -11,7 +13,9 @@ Today the repo still shows a mismatch between what the product foundations imply - 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 -- attested posture still has one ordering gap before post-handshake trusted verification closes the claim fully +- 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 @@ -21,7 +25,9 @@ Without a dedicated alpha hardening lane, RuneCode risks declaring beta too earl - 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 at least one honest useful first-party RuneContext workflow slice to be runnable and inspectable through the normal product path. +- 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. @@ -29,6 +35,7 @@ Without a dedicated alpha hardening lane, RuneCode risks declaring beta too earl - 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. @@ -44,7 +51,10 @@ That split is easier to reason about than continuing to leave integration and po ## 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 at least one real useful workflow runs through the honest trusted and untrusted execution path and is inspectable through the normal product surfaces. +- 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. @@ -53,12 +63,17 @@ That split is easier to reason about than continuing to leave integration and po - 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. -- Promising multiple equally complete first-party workflow families before beta. +- 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: -- one honest useful end-to-end workflow path +- 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 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 index cbc1dab9..135613af 100644 --- 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 @@ -4,13 +4,18 @@ - `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 index 2702d410..d3dd75e9 100644 --- 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 @@ -1,10 +1,10 @@ schema_version: 1 id: CHG-2026-060-c1a4-beta-readiness-hardening-product-polish title: Beta Readiness Hardening + Product Polish -status: proposed +status: implemented type: project size: large -verification_status: pending +verification_status: passed context_bundles: - product-planning - go-control-plane @@ -21,16 +21,22 @@ related_changes: - 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: [] 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 index 1d46e088..43883998 100644 --- 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 @@ -2,28 +2,44 @@ ## Phase 1: Close The End-To-End Execution Gap -- [ ] Trace the normal product path from session execution trigger to useful work and remove any remaining synthetic-only bridges. -- [ ] Wire trusted workflow selection and production adoption of authoritative built-in workflow assets for the supported first-party beta slice. -- [ ] Wire trusted `RunPlan` compilation and persistence into the real execution path rather than leaving it as largely test-proven foundation. -- [ ] Ensure the real execution path starts from the persisted authoritative plan identity. -- [ ] Ensure real runner checkpoint and result reporting reaches the broker through the typed production path. -- [ ] Remove ambiguity around noop/default runner transport behavior for the real supported workflow path. +- [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: Make One Workflow Honestly Useful +## Phase 2: Prove Canonical RuneContext Project Lifecycle -- [ ] Deliver one first-party RuneContext workflow slice that a user can run usefully on a real project through the normal product path. -- [ ] Prefer `change_draft` or `spec_draft` as the minimum honest useful workflow. -- [ ] If scope remains manageable, also wire `draft_promote_apply` through the same real path so verified RuneContext lifecycle mutation is exercised end to end. -- [ ] Keep the chosen slice inspectable through runs, sessions, artifacts, approvals, and audit surfaces. -- [ ] Ensure the supported workflow path remains Linux-first and does not depend on future platform work. +- [ ] 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: Align Runtime Assurance Truthfulness +## 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 lands. +- [ ] 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 4: TUI And Product Polish During Dogfooding +## 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. @@ -31,28 +47,38 @@ - [ ] 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 5: Verification Smoke Path +## Phase 6: Verification Smoke Path -- [ ] Run the supported useful workflow through the real product path and confirm canonical evidence is generated. -- [ ] Inspect the resulting run, artifacts, and audit records through normal product surfaces. -- [ ] Exercise audit evidence snapshot on the real workflow path. -- [ ] Exercise audit record inclusion on at least one real workflow-generated record. -- [ ] Exercise evidence bundle export and offline verification on the real workflow 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 6: Release-Surface Alignment +## Phase 7: Release-Surface Alignment -- [ ] Update roadmap and product-facing docs so alpha.11 is the hardening lane and beta.1 remains the milestone outcome. +- [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. -- [ ] Ensure release messaging does not imply a stronger end-to-end or attestation posture than the code actually provides. +- [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 has one honest useful workflow path that runs through the real trusted and untrusted execution path. -- [ ] Trusted `RunPlan` compilation and persistence are part of the real production workflow path. -- [ ] Runner progress shown to operators comes from real reporting integration for the supported path. -- [ ] The supported path is inspectable through session, run, artifact, approval, and audit surfaces. -- [ ] Verification artifacts are generated and exercised from the same real workflow path. +- [ ] 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. -- [ ] The beta story is more truthful and less scaffold-heavy after this alpha lane completes. +- [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 index 588ab1d3..c45677d6 100644 --- 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 @@ -3,18 +3,45 @@ ## 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 also reflected under `v0.1.0-alpha.11`. +- 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 one honest useful workflow path through the real trusted and untrusted execution path. +- 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. +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 index 36c56ee8..8ce94ce0 100644 --- 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 @@ -5,8 +5,8 @@ This change expands RuneCode's performance program beyond the MVP beta gate set The design goal is to preserve the MVP gate set as a stable release contract while adding broader post-MVP coverage for: -- broader first-party workflow-pack surfaces -- git-gateway and broader project-substrate paths +- 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 @@ -25,32 +25,32 @@ That means the post-MVP expansion should continue to use: ## Layer Boundary ### Layer 1: MVP Beta Gates -Owned by `CHG-053`: +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 +- 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 CHG-049 workflow-pack surfaces beyond the supported beta slice -- git-gateway and broader project-substrate performance suites +- 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 cover surfaces that are useful but were intentionally excluded from the MVP hard gate, such as: +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: -- draft artifact generation when it is no longer merely the minimum supported workflow slice -- explicit draft promote/apply timing through the audited shared path -- reviewed implementation-input-set validation or binding costs for approved-change implementation entry +- 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 @@ -64,8 +64,8 @@ This expansion lane should add explicit performance coverage for surfaces that a - git remote prepare - execute against local bare remotes -- project substrate posture and preview flows -- local project substrate apply flows +- 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. 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 index 0e6b34e9..49767bc6 100644 --- 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 @@ -1,11 +1,11 @@ ## Summary -Expand RuneCode's performance program beyond the MVP beta gate set to cover broader workflow-pack surfaces, git-gateway and broader project-substrate paths, heavier fixture tiers, and tuned cross-platform verification gates once the Linux-first beta baseline is already in place. +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. That is the right first boundary, but it intentionally leaves valuable follow-on work outside the beta-critical lane: +`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: -- broader CHG-049 workflow-pack entry and mutation surfaces beyond the supported beta slice -- git-gateway performance checks when remote mutation is not part of the MVP hard gate +- 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 @@ -17,8 +17,9 @@ Without a separate post-MVP change, those deferred surfaces would either drift w - 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 of broader CHG-049 first-party workflow-pack surfaces, including draft artifact generation, draft promote/apply, reviewed implementation-input-set validation or binding, direct CLI workflow triggering, repo-scoped admission control or idempotency, and fail-closed drift-triggered re-evaluation or recompilation costs when those surfaces are part of the supported product story. -- Add explicit performance checks for git-gateway and broader project-substrate paths when those surfaces become part of the supported user workflow. +- 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. @@ -34,7 +35,7 @@ That lets the first beta ship with serious performance discipline while still pr ## Assumptions - The MVP gate set from `CHG-053` lands first and becomes the baseline for future expansion. -- Broader workflow-pack surfaces and project-substrate or git-gateway flows are important to measure, but they should not redefine the first beta gate set retroactively. +- 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. @@ -42,6 +43,7 @@ That lets the first beta ship with serious performance discipline while still pr ## 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 @@ -49,8 +51,8 @@ This change keeps the broader performance program reviewable without making the If completed, RuneCode will gain a cleaner post-MVP path for: -- broader workflow-pack performance coverage -- git-gateway and broader project-substrate performance coverage +- 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` 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 index 57c5edd6..b84dea93 100644 --- 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 @@ -14,8 +14,8 @@ This change exists to expand RuneCode's performance program after the MVP gate s That includes freezing the following clarifications for post-MVP work: -- broader workflow-pack surfaces can gain explicit budgets without widening the first beta gate set retroactively -- git-gateway and broader project-substrate performance checks should remain deterministic and local-first where feasible +- 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 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 index 4832a8e5..1a501e1f 100644 --- 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 @@ -1,13 +1,14 @@ # Tasks -## Phase 1: Broader Workflow-Pack Coverage +## 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. -- [ ] Add deterministic checks for broader CHG-049 workflow-pack surfaces beyond the supported MVP beta slice. -- [ ] Add deterministic draft artifact-generation checks where those surfaces are part of the supported post-MVP product story. -- [ ] Add deterministic draft promote/apply 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. +- [ ] 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. @@ -18,7 +19,7 @@ - [ ] 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. +- [ ] 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 @@ -47,7 +48,7 @@ ## Acceptance Criteria -- [ ] RuneCode has explicit post-MVP performance checks for broader workflow-pack surfaces beyond the first beta gate set. +- [ ] 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. 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 index 408911c0..65ea96d0 100644 --- 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 @@ -7,14 +7,15 @@ ## Verification Notes - Confirm the roadmap places this change under `v0.2 (Post-MVP)`. -- Confirm `CHG-053` remains the MVP beta gate set and this change is explicitly additive over it. +- 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 CHG-049 workflow-pack surfaces, git-gateway and broader project-substrate paths, larger fixture ladders, and tuned cross-platform gates as the main deferred layer. +- 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`. +- 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 804a222e..53c66f90 100644 --- a/runecontext/project/roadmap.md +++ b/runecontext/project/roadmap.md @@ -8,13 +8,14 @@ Active lifecycle state lives in `runecontext/changes/*/status.yaml`, and durable ### v0.1.0-alpha.11 - Beta Readiness Hardening + Product Polish - - RuneCode closes the remaining product-integration gaps before beta by wiring one honest useful end-to-end workflow through the real trusted and untrusted execution path, tightening operator truth surfaces, and capturing dogfooding-driven polish across the local product UX with emphasis on the TUI. + - 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. + - 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) @@ -22,7 +23,7 @@ Active lifecycle state lives in `runecontext/changes/*/status.yaml`, and durable - 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 workflow-pack and git surfaces, larger fixture tiers, and tuned cross-platform verification gates beyond the Linux-first beta baseline. + - 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. @@ -64,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) @@ -189,4 +190,3 @@ Active lifecycle state lives in `runecontext/changes/*/status.yaml`, and durable - 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/standards/ci/just-ci.md b/runecontext/standards/ci/just-ci.md index 07f64877..c529e7ba 100644 --- a/runecontext/standards/ci/just-ci.md +++ b/runecontext/standards/ci/just-ci.md @@ -12,6 +12,7 @@ suggested_context_bundles: - `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`) @@ -32,9 +33,9 @@ ci-fast: go run github.com/golangci/golangci-lint/cmd/golangci-lint@... 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/runecontext/standards/testing/performance-contract-governance.md b/runecontext/standards/testing/performance-contract-governance.md index f2947595..793387f7 100644 --- a/runecontext/standards/testing/performance-contract-governance.md +++ b/runecontext/standards/testing/performance-contract-governance.md @@ -21,5 +21,7 @@ Use `tools/perfcontracts/manifest.json` as the authoritative inventory for check - 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/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-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..d743524f --- /dev/null +++ b/runner/src/runner-identifiers.ts @@ -0,0 +1,49 @@ +/** + * 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 = maxTokenLength > 0 && token.length > maxTokenLength ? token.slice(0, maxTokenLength) : token; + return `${prefix}_${boundedToken}_${suffix}`; +} + +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"; + } + const normalized = trimmed.replace(/[^a-z0-9_-]/g, "_").replace(/^[_-]+|[_-]+$/g, ""); + if (!normalized) { + return "scope"; + } + return /^[a-z]/.test(normalized) ? normalized : `s_${normalized}`; +} diff --git a/tools/perfcontracts/README.md b/tools/perfcontracts/README.md index cbd8659d..8f1cb191 100644 --- a/tools/perfcontracts/README.md +++ b/tools/perfcontracts/README.md @@ -15,6 +15,7 @@ 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 @@ -22,6 +23,16 @@ Each metric contract declares: ## 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. diff --git a/tools/perfcontracts/contracts/runner-workflow.v1.json b/tools/perfcontracts/contracts/runner-workflow.v1.json index c8e0a148..9aad8fb0 100644 --- a/tools/perfcontracts/contracts/runner-workflow.v1.json +++ b/tools/perfcontracts/contracts/runner-workflow.v1.json @@ -11,6 +11,7 @@ "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}, @@ -37,6 +38,7 @@ "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}, @@ -63,6 +65,7 @@ "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}, @@ -86,6 +89,7 @@ "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}, @@ -110,6 +114,7 @@ "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}, @@ -136,6 +141,7 @@ "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}, @@ -162,6 +168,7 @@ "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}, @@ -188,6 +195,7 @@ "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}, @@ -214,6 +222,7 @@ "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}, diff --git a/tools/perfcontracts/manifest.json b/tools/perfcontracts/manifest.json index 46852366..abd88c2b 100644 --- a/tools/perfcontracts/manifest.json +++ b/tools/perfcontracts/manifest.json @@ -3,6 +3,11 @@ "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"},